diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts new file mode 100644 index 00000000..286d47e1 --- /dev/null +++ b/src/lib/moderatePost_wrapped.ts @@ -0,0 +1,29 @@ +import {moderatePost} from '@atproto/api' + +type ModeratePost = typeof moderatePost +type Options = Parameters[1] & { + hiddenPosts?: string[] +} + +export function moderatePost_wrapped( + subject: Parameters[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 8ba99128..bf19c208 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 f689c3d0..cdb542f5 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -108,6 +108,7 @@ export function transform(legacy: Partial): 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 5ed8e01f..27b1f26b 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 @@ -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 00000000..11119ce7 --- /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( + persisted.defaults.hiddenPosts, +) +const apiContext = React.createContext({ + 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 ( + + {children} + + ) +} + +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 1f4348cf..5ec65903 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 ( - {children} + + {children} + ) } diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index cc594316..438879b7 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 b91af372..0e943622 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 872bb21a..a9aa7f26 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 2ff80307..6a377cdf 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 df7d9230..fca4171c 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 193bb9bd..1f2e067c 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', },