about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/lib/moderatePost_wrapped.ts29
-rw-r--r--src/lib/moderation.ts7
-rw-r--r--src/state/persisted/legacy.ts1
-rw-r--r--src/state/persisted/schema.ts2
-rw-r--r--src/state/preferences/hidden-posts.tsx64
-rw-r--r--src/state/preferences/index.tsx6
-rw-r--r--src/state/queries/notifications/util.ts2
-rw-r--r--src/state/queries/post-feed.ts8
-rw-r--r--src/state/queries/preferences/index.ts11
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx2
-rw-r--r--src/view/com/post/Post.tsx2
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx29
12 files changed, 151 insertions, 12 deletions
diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts
new file mode 100644
index 000000000..286d47e1d
--- /dev/null
+++ b/src/lib/moderatePost_wrapped.ts
@@ -0,0 +1,29 @@
+import {moderatePost} from '@atproto/api'
+
+type ModeratePost = typeof moderatePost
+type Options = Parameters<ModeratePost>[1] & {
+  hiddenPosts?: string[]
+}
+
+export function moderatePost_wrapped(
+  subject: Parameters<ModeratePost>[0],
+  opts: Options,
+) {
+  const {hiddenPosts = [], ...options} = opts
+  const moderations = moderatePost(subject, options)
+
+  if (hiddenPosts.includes(subject.uri)) {
+    moderations.content.filter = true
+    moderations.content.blur = true
+    if (!moderations.content.cause) {
+      moderations.content.cause = {
+        // @ts-ignore Temporary extension to the moderation system -prf
+        type: 'post-hidden',
+        source: {type: 'user'},
+        priority: 1,
+      }
+    }
+  }
+
+  return moderations
+}
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
index 8ba99128b..bf19c208a 100644
--- a/src/lib/moderation.ts
+++ b/src/lib/moderation.ts
@@ -60,6 +60,13 @@ export function describeModerationCause(
       }
     }
   }
+  // @ts-ignore Temporary extension to the moderation system -prf
+  if (cause.type === 'post-hidden') {
+    return {
+      name: 'Post Hidden by You',
+      description: 'You have hidden this post',
+    }
+  }
   return cause.labelDef.strings[context].en
 }
 
diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts
index f689c3d06..cdb542f5a 100644
--- a/src/state/persisted/legacy.ts
+++ b/src/state/persisted/legacy.ts
@@ -108,6 +108,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
     onboarding: {
       step: legacy.onboarding?.step || defaults.onboarding.step,
     },
+    hiddenPosts: defaults.hiddenPosts,
   }
 }
 
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index 5ed8e01f3..27b1f26bd 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -37,6 +37,7 @@ export const schema = z.object({
   onboarding: z.object({
     step: z.string(),
   }),
+  hiddenPosts: z.array(z.string()).optional(), // should move to server
 })
 export type Schema = z.infer<typeof schema>
 
@@ -66,4 +67,5 @@ export const defaults: Schema = {
   onboarding: {
     step: 'Home',
   },
+  hiddenPosts: [],
 }
diff --git a/src/state/preferences/hidden-posts.tsx b/src/state/preferences/hidden-posts.tsx
new file mode 100644
index 000000000..11119ce75
--- /dev/null
+++ b/src/state/preferences/hidden-posts.tsx
@@ -0,0 +1,64 @@
+import React from 'react'
+import * as persisted from '#/state/persisted'
+
+type SetStateCb = (
+  s: persisted.Schema['hiddenPosts'],
+) => persisted.Schema['hiddenPosts']
+type StateContext = persisted.Schema['hiddenPosts']
+type ApiContext = {
+  hidePost: ({uri}: {uri: string}) => void
+  unhidePost: ({uri}: {uri: string}) => void
+}
+
+const stateContext = React.createContext<StateContext>(
+  persisted.defaults.hiddenPosts,
+)
+const apiContext = React.createContext<ApiContext>({
+  hidePost: () => {},
+  unhidePost: () => {},
+})
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const [state, setState] = React.useState(persisted.get('hiddenPosts'))
+
+  const setStateWrapped = React.useCallback(
+    (fn: SetStateCb) => {
+      const s = fn(persisted.get('hiddenPosts'))
+      setState(s)
+      persisted.write('hiddenPosts', s)
+    },
+    [setState],
+  )
+
+  const api = React.useMemo(
+    () => ({
+      hidePost: ({uri}: {uri: string}) => {
+        setStateWrapped(s => [...(s || []), uri])
+      },
+      unhidePost: ({uri}: {uri: string}) => {
+        setStateWrapped(s => (s || []).filter(u => u !== uri))
+      },
+    }),
+    [setStateWrapped],
+  )
+
+  React.useEffect(() => {
+    return persisted.onUpdate(() => {
+      setState(persisted.get('hiddenPosts'))
+    })
+  }, [setStateWrapped])
+
+  return (
+    <stateContext.Provider value={state}>
+      <apiContext.Provider value={api}>{children}</apiContext.Provider>
+    </stateContext.Provider>
+  )
+}
+
+export function useHiddenPosts() {
+  return React.useContext(stateContext)
+}
+
+export function useHiddenPostsApi() {
+  return React.useContext(apiContext)
+}
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index 1f4348cfc..5ec659031 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -1,17 +1,21 @@
 import React from 'react'
 import {Provider as LanguagesProvider} from './languages'
 import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required'
+import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
 
 export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
 export {
   useRequireAltTextEnabled,
   useSetRequireAltTextEnabled,
 } from './alt-text-required'
+export * from './hidden-posts'
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
   return (
     <LanguagesProvider>
-      <AltTextRequiredProvider>{children}</AltTextRequiredProvider>
+      <AltTextRequiredProvider>
+        <HiddenPostsProvider>{children}</HiddenPostsProvider>
+      </AltTextRequiredProvider>
     </LanguagesProvider>
   )
 }
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index cc5943163..438879b7e 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -2,12 +2,12 @@ import {
   AppBskyNotificationListNotifications,
   ModerationOpts,
   moderateProfile,
-  moderatePost,
   AppBskyFeedDefs,
   AppBskyFeedPost,
   AppBskyFeedRepost,
   AppBskyFeedLike,
 } from '@atproto/api'
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import chunk from 'lodash.chunk'
 import {QueryClient} from '@tanstack/react-query'
 import {getAgent} from '../../session'
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index b91af372f..0e943622a 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -1,10 +1,5 @@
 import React, {useCallback, useEffect, useRef} from 'react'
-import {
-  AppBskyFeedDefs,
-  AppBskyFeedPost,
-  moderatePost,
-  PostModeration,
-} from '@atproto/api'
+import {AppBskyFeedDefs, AppBskyFeedPost, PostModeration} from '@atproto/api'
 import {
   useInfiniteQuery,
   InfiniteData,
@@ -12,6 +7,7 @@ import {
   QueryClient,
   useQueryClient,
 } from '@tanstack/react-query'
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {useFeedTuners} from '../preferences/feed-tuners'
 import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
 import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index 872bb21af..a9aa7f26e 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -19,6 +19,7 @@ import {
 } from '#/state/queries/preferences/const'
 import {getModerationOpts} from '#/state/queries/preferences/moderation'
 import {STALE} from '#/state/queries'
+import {useHiddenPosts} from '#/state/preferences/hidden-posts'
 
 export * from '#/state/queries/preferences/types'
 export * from '#/state/queries/preferences/moderation'
@@ -94,15 +95,21 @@ export function usePreferencesQuery() {
 export function useModerationOpts() {
   const {currentAccount} = useSession()
   const prefs = usePreferencesQuery()
+  const hiddenPosts = useHiddenPosts()
   const opts = useMemo(() => {
     if (!prefs.data) {
       return
     }
-    return getModerationOpts({
+    const moderationOpts = getModerationOpts({
       userDid: currentAccount?.did || '',
       preferences: prefs.data,
     })
-  }, [currentAccount?.did, prefs.data])
+
+    return {
+      ...moderationOpts,
+      hiddenPosts,
+    }
+  }, [currentAccount?.did, prefs.data, hiddenPosts])
   return opts
 }
 
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 2ff803071..6a377cdf6 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -5,9 +5,9 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
   RichText as RichTextAPI,
-  moderatePost,
   PostModeration,
 } from '@atproto/api'
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Link, TextLink} from '../util/Link'
 import {RichText} from '../util/text/RichText'
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index df7d92301..fca4171c3 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -4,10 +4,10 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
   AtUri,
-  moderatePost,
   PostModeration,
   RichText as RichTextAPI,
 } from '@atproto/api'
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Link, TextLink} from '../util/Link'
 import {UserInfoText} from '../util/UserInfoText'
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 193bb9bd7..1f2e067c2 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -18,6 +18,7 @@ import {getTranslatorLink} from '#/locale/helpers'
 import {usePostDeleteMutation} from '#/state/queries/post'
 import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
 import {useLanguagePrefs} from '#/state/preferences'
+import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
 import {logger} from '#/logger'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -50,9 +51,12 @@ let PostDropdownBtn = ({
   const mutedThreads = useMutedThreads()
   const toggleThreadMute = useToggleThreadMute()
   const postDeleteMutation = usePostDeleteMutation()
+  const hiddenPosts = useHiddenPosts()
+  const {hidePost} = useHiddenPostsApi()
 
   const rootUri = record.reply?.root?.uri || postUri
   const isThreadMuted = mutedThreads.includes(rootUri)
+  const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
   const isAuthor = postAuthor.did === currentAccount?.did
   const href = React.useMemo(() => {
     const urip = new AtUri(postUri)
@@ -98,6 +102,10 @@ let PostDropdownBtn = ({
     Linking.openURL(translatorUrl)
   }, [translatorUrl])
 
+  const onHidePost = React.useCallback(() => {
+    hidePost({uri: postUri})
+  }, [postUri, hidePost])
+
   const dropdownItems: NativeDropdownItem[] = [
     {
       label: _(msg`Translate`),
@@ -159,6 +167,27 @@ let PostDropdownBtn = ({
         web: 'comment-slash',
       },
     },
+    hasSession &&
+      !isAuthor &&
+      !isPostHidden && {
+        label: _(msg`Hide post`),
+        onPress() {
+          openModal({
+            name: 'confirm',
+            title: _(msg`Hide this post?`),
+            message: _(msg`This will hide this post from your feeds.`),
+            onPressConfirm: onHidePost,
+          })
+        },
+        testID: 'postDropdownHideBtn',
+        icon: {
+          ios: {
+            name: 'eye.slash',
+          },
+          android: 'ic_menu_delete',
+          web: ['far', 'eye-slash'],
+        },
+      },
     {
       label: 'separator',
     },