Bluesky app fork with some witchin' additions 💫

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>

authored by

Eric Bailey
Paul Frazee
and committed by
GitHub
b1994051 28e0df59

+151 -12
+29
src/lib/moderatePost_wrapped.ts
··· 1 + import {moderatePost} from '@atproto/api' 2 + 3 + type ModeratePost = typeof moderatePost 4 + type Options = Parameters<ModeratePost>[1] & { 5 + hiddenPosts?: string[] 6 + } 7 + 8 + export function moderatePost_wrapped( 9 + subject: Parameters<ModeratePost>[0], 10 + opts: Options, 11 + ) { 12 + const {hiddenPosts = [], ...options} = opts 13 + const moderations = moderatePost(subject, options) 14 + 15 + if (hiddenPosts.includes(subject.uri)) { 16 + moderations.content.filter = true 17 + moderations.content.blur = true 18 + if (!moderations.content.cause) { 19 + moderations.content.cause = { 20 + // @ts-ignore Temporary extension to the moderation system -prf 21 + type: 'post-hidden', 22 + source: {type: 'user'}, 23 + priority: 1, 24 + } 25 + } 26 + } 27 + 28 + return moderations 29 + }
+7
src/lib/moderation.ts
··· 60 60 } 61 61 } 62 62 } 63 + // @ts-ignore Temporary extension to the moderation system -prf 64 + if (cause.type === 'post-hidden') { 65 + return { 66 + name: 'Post Hidden by You', 67 + description: 'You have hidden this post', 68 + } 69 + } 63 70 return cause.labelDef.strings[context].en 64 71 } 65 72
+1
src/state/persisted/legacy.ts
··· 108 108 onboarding: { 109 109 step: legacy.onboarding?.step || defaults.onboarding.step, 110 110 }, 111 + hiddenPosts: defaults.hiddenPosts, 111 112 } 112 113 } 113 114
+2
src/state/persisted/schema.ts
··· 37 37 onboarding: z.object({ 38 38 step: z.string(), 39 39 }), 40 + hiddenPosts: z.array(z.string()).optional(), // should move to server 40 41 }) 41 42 export type Schema = z.infer<typeof schema> 42 43 ··· 66 67 onboarding: { 67 68 step: 'Home', 68 69 }, 70 + hiddenPosts: [], 69 71 }
+64
src/state/preferences/hidden-posts.tsx
··· 1 + import React from 'react' 2 + import * as persisted from '#/state/persisted' 3 + 4 + type SetStateCb = ( 5 + s: persisted.Schema['hiddenPosts'], 6 + ) => persisted.Schema['hiddenPosts'] 7 + type StateContext = persisted.Schema['hiddenPosts'] 8 + type ApiContext = { 9 + hidePost: ({uri}: {uri: string}) => void 10 + unhidePost: ({uri}: {uri: string}) => void 11 + } 12 + 13 + const stateContext = React.createContext<StateContext>( 14 + persisted.defaults.hiddenPosts, 15 + ) 16 + const apiContext = React.createContext<ApiContext>({ 17 + hidePost: () => {}, 18 + unhidePost: () => {}, 19 + }) 20 + 21 + export function Provider({children}: React.PropsWithChildren<{}>) { 22 + const [state, setState] = React.useState(persisted.get('hiddenPosts')) 23 + 24 + const setStateWrapped = React.useCallback( 25 + (fn: SetStateCb) => { 26 + const s = fn(persisted.get('hiddenPosts')) 27 + setState(s) 28 + persisted.write('hiddenPosts', s) 29 + }, 30 + [setState], 31 + ) 32 + 33 + const api = React.useMemo( 34 + () => ({ 35 + hidePost: ({uri}: {uri: string}) => { 36 + setStateWrapped(s => [...(s || []), uri]) 37 + }, 38 + unhidePost: ({uri}: {uri: string}) => { 39 + setStateWrapped(s => (s || []).filter(u => u !== uri)) 40 + }, 41 + }), 42 + [setStateWrapped], 43 + ) 44 + 45 + React.useEffect(() => { 46 + return persisted.onUpdate(() => { 47 + setState(persisted.get('hiddenPosts')) 48 + }) 49 + }, [setStateWrapped]) 50 + 51 + return ( 52 + <stateContext.Provider value={state}> 53 + <apiContext.Provider value={api}>{children}</apiContext.Provider> 54 + </stateContext.Provider> 55 + ) 56 + } 57 + 58 + export function useHiddenPosts() { 59 + return React.useContext(stateContext) 60 + } 61 + 62 + export function useHiddenPostsApi() { 63 + return React.useContext(apiContext) 64 + }
+5 -1
src/state/preferences/index.tsx
··· 1 1 import React from 'react' 2 2 import {Provider as LanguagesProvider} from './languages' 3 3 import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required' 4 + import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts' 4 5 5 6 export {useLanguagePrefs, useLanguagePrefsApi} from './languages' 6 7 export { 7 8 useRequireAltTextEnabled, 8 9 useSetRequireAltTextEnabled, 9 10 } from './alt-text-required' 11 + export * from './hidden-posts' 10 12 11 13 export function Provider({children}: React.PropsWithChildren<{}>) { 12 14 return ( 13 15 <LanguagesProvider> 14 - <AltTextRequiredProvider>{children}</AltTextRequiredProvider> 16 + <AltTextRequiredProvider> 17 + <HiddenPostsProvider>{children}</HiddenPostsProvider> 18 + </AltTextRequiredProvider> 15 19 </LanguagesProvider> 16 20 ) 17 21 }
+1 -1
src/state/queries/notifications/util.ts
··· 2 2 AppBskyNotificationListNotifications, 3 3 ModerationOpts, 4 4 moderateProfile, 5 - moderatePost, 6 5 AppBskyFeedDefs, 7 6 AppBskyFeedPost, 8 7 AppBskyFeedRepost, 9 8 AppBskyFeedLike, 10 9 } from '@atproto/api' 10 + import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 11 11 import chunk from 'lodash.chunk' 12 12 import {QueryClient} from '@tanstack/react-query' 13 13 import {getAgent} from '../../session'
+2 -6
src/state/queries/post-feed.ts
··· 1 1 import React, {useCallback, useEffect, useRef} from 'react' 2 - import { 3 - AppBskyFeedDefs, 4 - AppBskyFeedPost, 5 - moderatePost, 6 - PostModeration, 7 - } from '@atproto/api' 2 + import {AppBskyFeedDefs, AppBskyFeedPost, PostModeration} from '@atproto/api' 8 3 import { 9 4 useInfiniteQuery, 10 5 InfiniteData, ··· 12 7 QueryClient, 13 8 useQueryClient, 14 9 } from '@tanstack/react-query' 10 + import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 15 11 import {useFeedTuners} from '../preferences/feed-tuners' 16 12 import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip' 17 13 import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
+9 -2
src/state/queries/preferences/index.ts
··· 19 19 } from '#/state/queries/preferences/const' 20 20 import {getModerationOpts} from '#/state/queries/preferences/moderation' 21 21 import {STALE} from '#/state/queries' 22 + import {useHiddenPosts} from '#/state/preferences/hidden-posts' 22 23 23 24 export * from '#/state/queries/preferences/types' 24 25 export * from '#/state/queries/preferences/moderation' ··· 94 95 export function useModerationOpts() { 95 96 const {currentAccount} = useSession() 96 97 const prefs = usePreferencesQuery() 98 + const hiddenPosts = useHiddenPosts() 97 99 const opts = useMemo(() => { 98 100 if (!prefs.data) { 99 101 return 100 102 } 101 - return getModerationOpts({ 103 + const moderationOpts = getModerationOpts({ 102 104 userDid: currentAccount?.did || '', 103 105 preferences: prefs.data, 104 106 }) 105 - }, [currentAccount?.did, prefs.data]) 107 + 108 + return { 109 + ...moderationOpts, 110 + hiddenPosts, 111 + } 112 + }, [currentAccount?.did, prefs.data, hiddenPosts]) 106 113 return opts 107 114 } 108 115
+1 -1
src/view/com/post-thread/PostThreadItem.tsx
··· 5 5 AppBskyFeedDefs, 6 6 AppBskyFeedPost, 7 7 RichText as RichTextAPI, 8 - moderatePost, 9 8 PostModeration, 10 9 } from '@atproto/api' 10 + import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 11 11 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 12 12 import {Link, TextLink} from '../util/Link' 13 13 import {RichText} from '../util/text/RichText'
+1 -1
src/view/com/post/Post.tsx
··· 4 4 AppBskyFeedDefs, 5 5 AppBskyFeedPost, 6 6 AtUri, 7 - moderatePost, 8 7 PostModeration, 9 8 RichText as RichTextAPI, 10 9 } from '@atproto/api' 10 + import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 11 11 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 12 12 import {Link, TextLink} from '../util/Link' 13 13 import {UserInfoText} from '../util/UserInfoText'
+29
src/view/com/util/forms/PostDropdownBtn.tsx
··· 18 18 import {usePostDeleteMutation} from '#/state/queries/post' 19 19 import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' 20 20 import {useLanguagePrefs} from '#/state/preferences' 21 + import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' 21 22 import {logger} from '#/logger' 22 23 import {msg} from '@lingui/macro' 23 24 import {useLingui} from '@lingui/react' ··· 50 51 const mutedThreads = useMutedThreads() 51 52 const toggleThreadMute = useToggleThreadMute() 52 53 const postDeleteMutation = usePostDeleteMutation() 54 + const hiddenPosts = useHiddenPosts() 55 + const {hidePost} = useHiddenPostsApi() 53 56 54 57 const rootUri = record.reply?.root?.uri || postUri 55 58 const isThreadMuted = mutedThreads.includes(rootUri) 59 + const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) 56 60 const isAuthor = postAuthor.did === currentAccount?.did 57 61 const href = React.useMemo(() => { 58 62 const urip = new AtUri(postUri) ··· 97 101 const onOpenTranslate = React.useCallback(() => { 98 102 Linking.openURL(translatorUrl) 99 103 }, [translatorUrl]) 104 + 105 + const onHidePost = React.useCallback(() => { 106 + hidePost({uri: postUri}) 107 + }, [postUri, hidePost]) 100 108 101 109 const dropdownItems: NativeDropdownItem[] = [ 102 110 { ··· 159 167 web: 'comment-slash', 160 168 }, 161 169 }, 170 + hasSession && 171 + !isAuthor && 172 + !isPostHidden && { 173 + label: _(msg`Hide post`), 174 + onPress() { 175 + openModal({ 176 + name: 'confirm', 177 + title: _(msg`Hide this post?`), 178 + message: _(msg`This will hide this post from your feeds.`), 179 + onPressConfirm: onHidePost, 180 + }) 181 + }, 182 + testID: 'postDropdownHideBtn', 183 + icon: { 184 + ios: { 185 + name: 'eye.slash', 186 + }, 187 + android: 'ic_menu_delete', 188 + web: ['far', 'eye-slash'], 189 + }, 190 + }, 162 191 { 163 192 label: 'separator', 164 193 },