about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-02-06 11:51:40 -0600
committerGitHub <noreply@github.com>2025-02-06 11:51:40 -0600
commit9cd4f92027774029234e38980fac3a12f136166f (patch)
tree52805dd7ba11a128bc0cf582c984d89d9a7c390d /src
parent1db2668a96208046ffe316114f65d432e57db994 (diff)
downloadvoidsky-9cd4f92027774029234e38980fac3a12f136166f.tar.zst
[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>
Diffstat (limited to 'src')
-rw-r--r--src/Navigation.tsx9
-rw-r--r--src/components/dialogs/PostInteractionSettingsDialog.tsx82
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/routes.ts1
-rw-r--r--src/screens/Moderation/index.tsx16
-rw-r--r--src/screens/ModerationInteractionSettings/index.tsx127
-rw-r--r--src/screens/Settings/components/ExportCarDialog.tsx2
-rw-r--r--src/state/queries/post-interaction-settings.ts20
-rw-r--r--src/state/queries/preferences/const.ts4
-rw-r--r--src/view/com/composer/Composer.tsx10
-rw-r--r--src/view/com/composer/state/composer.ts25
11 files changed, 265 insertions, 32 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index a6332c5d8..0dcce98bf 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -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'
@@ -156,6 +157,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         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}
         options={{title: title(msg`Settings`), requireAuth: true}}
diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx
index a698574a4..b443d59f2 100644
--- a/src/components/dialogs/PostInteractionSettingsDialog.tsx
+++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx
@@ -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"
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 66ee7bffa..8b69a66c4 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -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}
diff --git a/src/routes.ts b/src/routes.ts
index 8541d4254..576ac92d1 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -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',
diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx
index 6b4dd06bc..55cc67f8c 100644
--- a/src/screens/Moderation/index.tsx
+++ b/src/screens/Moderation/index.tsx
@@ -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`)}
diff --git a/src/screens/ModerationInteractionSettings/index.tsx b/src/screens/ModerationInteractionSettings/index.tsx
new file mode 100644
index 000000000..99b29d950
--- /dev/null
+++ b/src/screens/ModerationInteractionSettings/index.tsx
@@ -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>}
+    </>
+  )
+}
diff --git a/src/screens/Settings/components/ExportCarDialog.tsx b/src/screens/Settings/components/ExportCarDialog.tsx
index 2de3895d3..685707259 100644
--- a/src/screens/Settings/components/ExportCarDialog.tsx
+++ b/src/screens/Settings/components/ExportCarDialog.tsx
@@ -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) {
diff --git a/src/state/queries/post-interaction-settings.ts b/src/state/queries/post-interaction-settings.ts
new file mode 100644
index 000000000..a256f2956
--- /dev/null
+++ b/src/state/queries/post-interaction-settings.ts
@@ -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,
+      })
+    },
+  })
+}
diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts
index 549f7ce29..3c1fead5e 100644
--- a/src/state/queries/preferences/const.ts
+++ b/src/state/queries/preferences/const.ts
@@ -39,4 +39,8 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
     activeProgressGuide: undefined,
     nuxs: [],
   },
+  postInteractionSettings: {
+    threadgateAllowRules: undefined,
+    postgateEmbeddingRules: [],
+  },
 }
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 0e9b52ce0..78293f618 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -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,
   )
 
diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts
index 6d4f10297..f5a55f175 100644
--- a/src/view/com/composer/state/composer.ts
+++ b/src/view/com/composer/state/composer.ts
@@ -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,
+      }),
     },
   }
 }