[APP-1013] Configure and apply default post interaction settings from user preferences (#7664)

* Add interaction settings screen

* Move header out of interaction settings form

* WIP hook it up

* Thread through default settings into composer

* Update copy pasta

* Handle edited state

* Copy feedback

* Sentence case

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

* Bump SDK

* Fix new type error

* Less in your face

* Remove new dep

* Add slot

* Copy edit

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
This commit is contained in:
Eric Bailey
2025-02-06 11:51:40 -06:00
committed by GitHub
parent 1db2668a96
commit 9cd4f92027
13 changed files with 298 additions and 98 deletions
+9
View File
@@ -70,6 +70,7 @@ import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
@@ -155,6 +156,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => ModerationBlockedAccounts}
options={{title: title(msg`Blocked Accounts`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationInteractionSettings"
getComponent={() => ModerationInteractionSettings}
options={{
title: title(msg`Post Interaction Settings`),
requireAuth: true,
}}
/>
<Stack.Screen
name="Settings"
getComponent={() => SettingsScreen}
@@ -40,6 +40,7 @@ import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export type PostInteractionSettingsFormProps = {
canSave?: boolean
onSave: () => void
isSaving?: boolean
@@ -58,20 +59,53 @@ export function PostInteractionSettingsControlledDialog({
}: PostInteractionSettingsFormProps & {
control: Dialog.DialogControlProps
}) {
const t = useTheme()
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
<PostInteractionSettingsForm {...rest} />
<View style={[a.gap_md]}>
<Header />
<PostInteractionSettingsForm {...rest} />
<Text
style={[
a.pt_sm,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
You can set default interaction settings in{' '}
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
Settings &rarr; Moderation &rarr; Interaction settings.
</Text>
</Trans>
</Text>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
export function Header() {
return (
<View style={[a.gap_md, a.pb_sm]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Post interaction settings</Trans>
</Text>
<Text style={[a.text_md, a.pb_xs]}>
<Trans>Customize who can interact with this post.</Trans>
</Text>
<Divider />
</View>
)
}
export type PostInteractionSettingsDialogProps = {
control: Dialog.DialogControlProps
/**
@@ -203,26 +237,31 @@ export function PostInteractionSettingsDialogControlledInner(
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
{isLoading ? (
<View style={[a.flex_1, a.py_4xl, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
) : (
<PostInteractionSettingsForm
replySettingsDisabled={!isThreadgateOwnedByViewer}
isSaving={isSaving}
onSave={onSave}
postgate={postgateValue}
onChangePostgate={setEditedPostgate}
threadgateAllowUISettings={allowUIValue}
onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
/>
)}
<View style={[a.gap_md]}>
<Header />
{isLoading ? (
<View style={[a.flex_1, a.py_4xl, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
) : (
<PostInteractionSettingsForm
replySettingsDisabled={!isThreadgateOwnedByViewer}
isSaving={isSaving}
onSave={onSave}
postgate={postgateValue}
onChangePostgate={setEditedPostgate}
threadgateAllowUISettings={allowUIValue}
onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
/>
)}
</View>
</Dialog.ScrollableInner>
)
}
export function PostInteractionSettingsForm({
canSave = true,
onSave,
isSaving,
postgate,
@@ -283,17 +322,7 @@ export function PostInteractionSettingsForm({
return (
<View>
<View style={[a.flex_1, a.gap_md]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Post interaction settings</Trans>
</Text>
<View style={[a.gap_lg]}>
<Text style={[a.text_md]}>
<Trans>Customize who can interact with this post.</Trans>
</Text>
<Divider />
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Quote settings</Trans>
@@ -435,6 +464,7 @@ export function PostInteractionSettingsForm({
</View>
<Button
disabled={!canSave || isSaving}
label={_(msg`Save`)}
onPress={onSave}
color="primary"
+1
View File
@@ -12,6 +12,7 @@ export type CommonNavigatorParams = {
ModerationModlists: undefined
ModerationMutedAccounts: undefined
ModerationBlockedAccounts: undefined
ModerationInteractionSettings: undefined
Settings: undefined
Profile: {name: string; hideBackButton?: boolean}
ProfileFollowers: {name: string}
+1
View File
@@ -13,6 +13,7 @@ export const router = new Router({
ModerationModlists: '/moderation/modlists',
ModerationMutedAccounts: '/moderation/muted-accounts',
ModerationBlockedAccounts: '/moderation/blocked-accounts',
ModerationInteractionSettings: '/moderation/interaction-settings',
// profiles, threads, lists
Profile: ['/profile/:name', '/profile/:name/rss'],
ProfileFollowers: '/profile/:name/followers',
+16
View File
@@ -28,6 +28,7 @@ import * as Toggle from '#/components/forms/Toggle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {Props as SVGIconProps} from '#/components/icons/common'
import {EditBig_Stroke2_Corner0_Rounded as EditBig} from '#/components/icons/EditBig'
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
@@ -199,6 +200,21 @@ export function ModerationScreenInner({
a.overflow_hidden,
t.atoms.bg_contrast_25,
]}>
<Link
label={_(msg`View your default post interaction settings`)}
testID="interactionSettingsBtn"
to="/moderation/interaction-settings">
{state => (
<SubItem
title={_(msg`Interaction settings`)}
icon={EditBig}
style={[
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
]}
/>
)}
</Link>
<Divider />
<Button
testID="mutedWordsBtn"
label={_(msg`Open muted words and tags settings`)}
@@ -0,0 +1,127 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import deepEqual from 'lodash.isequal'
import {logger} from '#/logger'
import {usePostInteractionSettingsMutation} from '#/state/queries/post-interaction-settings'
import {createPostgateRecord} from '#/state/queries/postgate/util'
import {
usePreferencesQuery,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences'
import {
threadgateAllowUISettingToAllowRecordValue,
threadgateRecordToAllowUISetting,
} from '#/state/queries/threadgate'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useGutters} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {PostInteractionSettingsForm} from '#/components/dialogs/PostInteractionSettingsDialog'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
export function Screen() {
const gutters = useGutters(['base'])
const {data: preferences} = usePreferencesQuery()
return (
<Layout.Screen testID="ModerationInteractionSettingsScreen">
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Post Interaction Settings</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<View style={[gutters, a.gap_xl]}>
<Admonition type="tip">
<Trans>
The following settings will be used as your defaults when creating
new posts. You can edit these for a specific post from the
composer.
</Trans>
</Admonition>
{preferences ? (
<Inner preferences={preferences} />
) : (
<View style={[gutters, a.justify_center, a.align_center]}>
<Loader size="xl" />
</View>
)}
</View>
</Layout.Content>
</Layout.Screen>
)
}
function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) {
const {_} = useLingui()
const {mutateAsync: setPostInteractionSettings, isPending} =
usePostInteractionSettingsMutation()
const [error, setError] = React.useState<string | undefined>(undefined)
const allowUI = React.useMemo(() => {
return threadgateRecordToAllowUISetting({
$type: 'app.bsky.feed.threadgate',
post: '',
createdAt: new Date().toString(),
allow: preferences.postInteractionSettings.threadgateAllowRules,
})
}, [preferences.postInteractionSettings.threadgateAllowRules])
const postgate = React.useMemo(() => {
return createPostgateRecord({
post: '',
embeddingRules:
preferences.postInteractionSettings.postgateEmbeddingRules,
})
}, [preferences.postInteractionSettings.postgateEmbeddingRules])
const [maybeEditedAllowUI, setAllowUI] = React.useState(allowUI)
const [maybeEditedPostgate, setEditedPostgate] = React.useState(postgate)
const wasEdited = React.useMemo(() => {
return (
!deepEqual(allowUI, maybeEditedAllowUI) ||
!deepEqual(postgate.embeddingRules, maybeEditedPostgate.embeddingRules)
)
}, [postgate, allowUI, maybeEditedAllowUI, maybeEditedPostgate])
const onSave = React.useCallback(async () => {
setError('')
try {
await setPostInteractionSettings({
threadgateAllowRules:
threadgateAllowUISettingToAllowRecordValue(maybeEditedAllowUI),
postgateEmbeddingRules: maybeEditedPostgate.embeddingRules ?? [],
})
Toast.show(_(msg`Settings saved`))
} catch (e: any) {
logger.error(`Failed to save post interaction settings`, {
context: 'ModerationInteractionSettingsScreen',
safeMessage: e.message,
})
setError(_(msg`Failed to save settings. Please try again.`))
}
}, [_, maybeEditedPostgate, maybeEditedAllowUI, setPostInteractionSettings])
return (
<>
<PostInteractionSettingsForm
canSave={wasEdited}
isSaving={isPending}
onSave={onSave}
postgate={maybeEditedPostgate}
onChangePostgate={setEditedPostgate}
threadgateAllowUISettings={maybeEditedAllowUI}
onChangeThreadgateAllowUISettings={setAllowUI}
/>
{error && <Admonition type="error">{error}</Admonition>}
</>
)
}
@@ -36,7 +36,7 @@ export function ExportCarDialog({
const saveRes = await saveBytesToDisk(
'repo.car',
downloadRes.data,
downloadRes.headers['content-type'],
downloadRes.headers['content-type'] || 'application/vnd.ipld.car',
)
if (saveRes) {
@@ -0,0 +1,20 @@
import {AppBskyActorDefs} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {preferencesQueryKey} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
export function usePostInteractionSettingsMutation() {
const qc = useQueryClient()
const agent = useAgent()
return useMutation({
async mutationFn(props: AppBskyActorDefs.PostInteractionSettingsPref) {
await agent.setPostInteractionSettings(props)
},
async onSuccess() {
await qc.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
+4
View File
@@ -39,4 +39,8 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
activeProgressGuide: undefined,
nuxs: [],
},
postInteractionSettings: {
threadgateAllowRules: undefined,
postgateEmbeddingRules: [],
},
}
+9 -1
View File
@@ -83,6 +83,7 @@ import {
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useProfileQuery} from '#/state/queries/profile'
import {Gif} from '#/state/queries/tenor'
import {useAgent, useSession} from '#/state/session'
@@ -169,6 +170,7 @@ export const ComposePost = ({
const discardPromptControl = Prompt.usePromptControl()
const {closeAllDialogs} = useDialogStateControlContext()
const {closeAllModals} = useModalControls()
const {data: preferences} = usePreferencesQuery()
const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
const [isPublishing, setIsPublishing] = useState(false)
@@ -177,7 +179,13 @@ export const ComposePost = ({
const [composerState, composerDispatch] = useReducer(
composerReducer,
{initImageUris, initQuoteUri: initQuote?.uri, initText, initMention},
{
initImageUris,
initQuoteUri: initQuote?.uri,
initText,
initMention,
initInteractionSettings: preferences?.postInteractionSettings,
},
createComposerState,
)
+21 -4
View File
@@ -1,5 +1,10 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {AppBskyFeedPostgate, AppBskyRichtextFacet, RichText} from '@atproto/api'
import {
AppBskyFeedPostgate,
AppBskyRichtextFacet,
BskyPreferences,
RichText,
} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure'
import {SelfLabel} from '#/lib/moderation'
@@ -13,7 +18,7 @@ import {
import {ComposerImage, createInitialImages} from '#/state/gallery'
import {createPostgateRecord} from '#/state/queries/postgate/util'
import {Gif} from '#/state/queries/tenor'
import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate'
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
import {ComposerOpts} from '#/state/shell/composer'
import {
@@ -477,11 +482,15 @@ export function createComposerState({
initMention,
initImageUris,
initQuoteUri,
initInteractionSettings,
}: {
initText: string | undefined
initMention: string | undefined
initImageUris: ComposerOpts['imageUris']
initQuoteUri: string | undefined
initInteractionSettings:
| BskyPreferences['postInteractionSettings']
| undefined
}): ComposerState {
let media: ImagesMedia | undefined
if (initImageUris?.length) {
@@ -591,8 +600,16 @@ export function createComposerState({
},
},
],
postgate: createPostgateRecord({post: ''}),
threadgate: threadgateViewToAllowUISetting(undefined),
postgate: createPostgateRecord({
post: '',
embeddingRules: initInteractionSettings?.postgateEmbeddingRules || [],
}),
threadgate: threadgateRecordToAllowUISetting({
$type: 'app.bsky.feed.threadgate',
post: '',
createdAt: new Date().toString(),
allow: initInteractionSettings?.threadgateAllowRules,
}),
},
}
}