Hide posts tool (#2299)

* Set up hidden posts persisted state

* Wrap moderatePost

* Integrate hidden posts into moderation

* Complete hide-post behaviors

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
Eric Bailey 2023-12-24 16:24:56 -06:00 committed by GitHub
parent 28e0df595f
commit b199405134
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 151 additions and 12 deletions

View File

@ -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
}

View File

@ -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 return cause.labelDef.strings[context].en
} }

View File

@ -108,6 +108,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
onboarding: { onboarding: {
step: legacy.onboarding?.step || defaults.onboarding.step, step: legacy.onboarding?.step || defaults.onboarding.step,
}, },
hiddenPosts: defaults.hiddenPosts,
} }
} }

View File

@ -37,6 +37,7 @@ export const schema = z.object({
onboarding: z.object({ onboarding: z.object({
step: z.string(), step: z.string(),
}), }),
hiddenPosts: z.array(z.string()).optional(), // should move to server
}) })
export type Schema = z.infer<typeof schema> export type Schema = z.infer<typeof schema>
@ -66,4 +67,5 @@ export const defaults: Schema = {
onboarding: { onboarding: {
step: 'Home', step: 'Home',
}, },
hiddenPosts: [],
} }

View File

@ -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)
}

View File

@ -1,17 +1,21 @@
import React from 'react' import React from 'react'
import {Provider as LanguagesProvider} from './languages' import {Provider as LanguagesProvider} from './languages'
import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required' import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required'
import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
export {useLanguagePrefs, useLanguagePrefsApi} from './languages' export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
export { export {
useRequireAltTextEnabled, useRequireAltTextEnabled,
useSetRequireAltTextEnabled, useSetRequireAltTextEnabled,
} from './alt-text-required' } from './alt-text-required'
export * from './hidden-posts'
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
return ( return (
<LanguagesProvider> <LanguagesProvider>
<AltTextRequiredProvider>{children}</AltTextRequiredProvider> <AltTextRequiredProvider>
<HiddenPostsProvider>{children}</HiddenPostsProvider>
</AltTextRequiredProvider>
</LanguagesProvider> </LanguagesProvider>
) )
} }

View File

@ -2,12 +2,12 @@ import {
AppBskyNotificationListNotifications, AppBskyNotificationListNotifications,
ModerationOpts, ModerationOpts,
moderateProfile, moderateProfile,
moderatePost,
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
AppBskyFeedRepost, AppBskyFeedRepost,
AppBskyFeedLike, AppBskyFeedLike,
} from '@atproto/api' } from '@atproto/api'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import chunk from 'lodash.chunk' import chunk from 'lodash.chunk'
import {QueryClient} from '@tanstack/react-query' import {QueryClient} from '@tanstack/react-query'
import {getAgent} from '../../session' import {getAgent} from '../../session'

View File

@ -1,10 +1,5 @@
import React, {useCallback, useEffect, useRef} from 'react' import React, {useCallback, useEffect, useRef} from 'react'
import { import {AppBskyFeedDefs, AppBskyFeedPost, PostModeration} from '@atproto/api'
AppBskyFeedDefs,
AppBskyFeedPost,
moderatePost,
PostModeration,
} from '@atproto/api'
import { import {
useInfiniteQuery, useInfiniteQuery,
InfiniteData, InfiniteData,
@ -12,6 +7,7 @@ import {
QueryClient, QueryClient,
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {useFeedTuners} from '../preferences/feed-tuners' import {useFeedTuners} from '../preferences/feed-tuners'
import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip' import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types' import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'

View File

@ -19,6 +19,7 @@ import {
} from '#/state/queries/preferences/const' } from '#/state/queries/preferences/const'
import {getModerationOpts} from '#/state/queries/preferences/moderation' import {getModerationOpts} from '#/state/queries/preferences/moderation'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {useHiddenPosts} from '#/state/preferences/hidden-posts'
export * from '#/state/queries/preferences/types' export * from '#/state/queries/preferences/types'
export * from '#/state/queries/preferences/moderation' export * from '#/state/queries/preferences/moderation'
@ -94,15 +95,21 @@ export function usePreferencesQuery() {
export function useModerationOpts() { export function useModerationOpts() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const prefs = usePreferencesQuery() const prefs = usePreferencesQuery()
const hiddenPosts = useHiddenPosts()
const opts = useMemo(() => { const opts = useMemo(() => {
if (!prefs.data) { if (!prefs.data) {
return return
} }
return getModerationOpts({ const moderationOpts = getModerationOpts({
userDid: currentAccount?.did || '', userDid: currentAccount?.did || '',
preferences: prefs.data, preferences: prefs.data,
}) })
}, [currentAccount?.did, prefs.data])
return {
...moderationOpts,
hiddenPosts,
}
}, [currentAccount?.did, prefs.data, hiddenPosts])
return opts return opts
} }

View File

@ -5,9 +5,9 @@ import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
RichText as RichTextAPI, RichText as RichTextAPI,
moderatePost,
PostModeration, PostModeration,
} from '@atproto/api' } from '@atproto/api'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Link, TextLink} from '../util/Link' import {Link, TextLink} from '../util/Link'
import {RichText} from '../util/text/RichText' import {RichText} from '../util/text/RichText'

View File

@ -4,10 +4,10 @@ import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
AtUri, AtUri,
moderatePost,
PostModeration, PostModeration,
RichText as RichTextAPI, RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Link, TextLink} from '../util/Link' import {Link, TextLink} from '../util/Link'
import {UserInfoText} from '../util/UserInfoText' import {UserInfoText} from '../util/UserInfoText'

View File

@ -18,6 +18,7 @@ import {getTranslatorLink} from '#/locale/helpers'
import {usePostDeleteMutation} from '#/state/queries/post' import {usePostDeleteMutation} from '#/state/queries/post'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
import {logger} from '#/logger' import {logger} from '#/logger'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@ -50,9 +51,12 @@ let PostDropdownBtn = ({
const mutedThreads = useMutedThreads() const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute() const toggleThreadMute = useToggleThreadMute()
const postDeleteMutation = usePostDeleteMutation() const postDeleteMutation = usePostDeleteMutation()
const hiddenPosts = useHiddenPosts()
const {hidePost} = useHiddenPostsApi()
const rootUri = record.reply?.root?.uri || postUri const rootUri = record.reply?.root?.uri || postUri
const isThreadMuted = mutedThreads.includes(rootUri) const isThreadMuted = mutedThreads.includes(rootUri)
const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
const isAuthor = postAuthor.did === currentAccount?.did const isAuthor = postAuthor.did === currentAccount?.did
const href = React.useMemo(() => { const href = React.useMemo(() => {
const urip = new AtUri(postUri) const urip = new AtUri(postUri)
@ -98,6 +102,10 @@ let PostDropdownBtn = ({
Linking.openURL(translatorUrl) Linking.openURL(translatorUrl)
}, [translatorUrl]) }, [translatorUrl])
const onHidePost = React.useCallback(() => {
hidePost({uri: postUri})
}, [postUri, hidePost])
const dropdownItems: NativeDropdownItem[] = [ const dropdownItems: NativeDropdownItem[] = [
{ {
label: _(msg`Translate`), label: _(msg`Translate`),
@ -159,6 +167,27 @@ let PostDropdownBtn = ({
web: 'comment-slash', 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', label: 'separator',
}, },