From 6616a6467ec53aa71e5f823c2d8c46dc01442703 Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Wed, 21 Aug 2024 21:20:45 -0500
Subject: Detached QPs and hidden replies (#4878)

Co-authored-by: Hailey <me@haileyok.com>
---
 src/components/WhoCanReply.tsx | 293 ++++++++++++++++++-----------------------
 1 file changed, 127 insertions(+), 166 deletions(-)

(limited to 'src/components/WhoCanReply.tsx')

diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx
index 1ffb4da39..ab6ef8293 100644
--- a/src/components/WhoCanReply.tsx
+++ b/src/components/WhoCanReply.tsx
@@ -1,39 +1,34 @@
 import React from 'react'
-import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
+import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
 import {
   AppBskyFeedDefs,
-  AppBskyFeedGetPostThread,
+  AppBskyFeedPost,
   AppBskyGraphDefs,
   AtUri,
-  BskyAgent,
 } from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useQueryClient} from '@tanstack/react-query'
 
-import {createThreadgate} from '#/lib/api'
-import {until} from '#/lib/async/until'
 import {HITSLOP_10} from '#/lib/constants'
 import {makeListLink, makeProfileLink} from '#/lib/routes/links'
-import {logger} from '#/logger'
 import {isNative} from '#/platform/detection'
-import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
 import {
-  ThreadgateSetting,
-  threadgateViewToSettings,
+  ThreadgateAllowUISetting,
+  threadgateViewToAllowUISetting,
 } from '#/state/queries/threadgate'
-import {useAgent} from '#/state/session'
-import * as Toast from 'view/com/util/Toast'
 import {atoms as a, useTheme} from '#/alf'
 import {Button} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
 import {useDialogControl} from '#/components/Dialog'
+import {
+  PostInteractionSettingsDialog,
+  usePrefetchPostInteractionSettings,
+} from '#/components/dialogs/PostInteractionSettingsDialog'
 import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
 import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
 import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
+import {InlineLinkText} from '#/components/Link'
 import {Text} from '#/components/Typography'
-import {TextLink} from '../view/com/util/Link'
-import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor'
 import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
 
 interface WhoCanReplyProps {
@@ -47,31 +42,34 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
   const t = useTheme()
   const infoDialogControl = useDialogControl()
   const editDialogControl = useDialogControl()
-  const agent = useAgent()
-  const queryClient = useQueryClient()
 
-  const settings = React.useMemo(
-    () => threadgateViewToSettings(post.threadgate),
-    [post],
-  )
-  const isRootPost = !('reply' in post.record)
+  /*
+   * `WhoCanReply` is only used for root posts atm, in case this changes
+   * unexpectedly, we should check to make sure it's for sure the root URI.
+   */
+  const rootUri =
+    AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root
+      ? post.record.reply.root.uri
+      : post.uri
+  const settings = React.useMemo(() => {
+    return threadgateViewToAllowUISetting(post.threadgate)
+  }, [post.threadgate])
 
-  if (!isRootPost) {
-    return null
-  }
-  if (!settings.length && !isThreadAuthor) {
-    return null
-  }
+  const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
+    postUri: post.uri,
+    rootPostUri: rootUri,
+  })
 
-  const isEverybody = settings.length === 0
-  const isNobody = !!settings.find(gate => gate.type === 'nobody')
-  const description = isEverybody
+  const anyoneCanReply =
+    settings.length === 1 && settings[0].type === 'everybody'
+  const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody'
+  const description = anyoneCanReply
     ? _(msg`Everybody can reply`)
-    : isNobody
+    : noOneCanReply
     ? _(msg`Replies disabled`)
     : _(msg`Some people can reply`)
 
-  const onPressEdit = () => {
+  const onPressOpen = () => {
     if (isNative && Keyboard.isVisible()) {
       Keyboard.dismiss()
     }
@@ -82,52 +80,23 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
     }
   }
 
-  const onEditConfirm = async (newSettings: ThreadgateSetting[]) => {
-    if (JSON.stringify(settings) === JSON.stringify(newSettings)) {
-      return
-    }
-    try {
-      if (newSettings.length) {
-        await createThreadgate(agent, post.uri, newSettings)
-      } else {
-        await agent.api.com.atproto.repo.deleteRecord({
-          repo: agent.session!.did,
-          collection: 'app.bsky.feed.threadgate',
-          rkey: new AtUri(post.uri).rkey,
-        })
-      }
-      await whenAppViewReady(agent, post.uri, res => {
-        const thread = res.data.thread
-        if (AppBskyFeedDefs.isThreadViewPost(thread)) {
-          const fetchedSettings = threadgateViewToSettings(
-            thread.post.threadgate,
-          )
-          return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
-        }
-        return false
-      })
-      Toast.show(_(msg`Thread settings updated`))
-      queryClient.invalidateQueries({
-        queryKey: [POST_THREAD_RQKEY_ROOT],
-      })
-    } catch (err) {
-      Toast.show(
-        _(
-          msg`There was an issue. Please check your internet connection and try again.`,
-        ),
-        'xmark',
-      )
-      logger.error('Failed to edit threadgate', {message: err})
-    }
-  }
-
   return (
     <>
       <Button
         label={
           isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
         }
-        onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open}
+        onPress={onPressOpen}
+        {...(isThreadAuthor
+          ? Platform.select({
+              web: {
+                onHoverIn: prefetchPostInteractionSettings,
+              },
+              native: {
+                onPressIn: prefetchPostInteractionSettings,
+              },
+            })
+          : {})}
         hitSlop={HITSLOP_10}>
         {({hovered}) => (
           <View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
@@ -145,22 +114,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
               ]}>
               {description}
             </Text>
+
             {isThreadAuthor && (
               <PencilLine width={12} fill={t.palette.primary_500} />
             )}
           </View>
         )}
       </Button>
-      <WhoCanReplyDialog
-        control={infoDialogControl}
-        post={post}
-        settings={settings}
-      />
-      {isThreadAuthor && (
-        <ThreadgateEditorDialog
+
+      {isThreadAuthor ? (
+        <PostInteractionSettingsDialog
+          postUri={post.uri}
+          rootPostUri={rootUri}
           control={editDialogControl}
-          threadgate={settings}
-          onConfirm={onEditConfirm}
+          initialThreadgateView={post.threadgate}
+        />
+      ) : (
+        <WhoCanReplyDialog
+          control={infoDialogControl}
+          post={post}
+          settings={settings}
+          embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
         />
       )}
     </>
@@ -174,7 +148,7 @@ function Icon({
 }: {
   color: string
   width?: number
-  settings: ThreadgateSetting[]
+  settings: ThreadgateAllowUISetting[]
 }) {
   const isEverybody = settings.length === 0
   const isNobody = !!settings.find(gate => gate.type === 'nobody')
@@ -186,79 +160,84 @@ function WhoCanReplyDialog({
   control,
   post,
   settings,
+  embeddingDisabled,
 }: {
   control: Dialog.DialogControlProps
   post: AppBskyFeedDefs.PostView
-  settings: ThreadgateSetting[]
+  settings: ThreadgateAllowUISetting[]
+  embeddingDisabled: boolean
 }) {
+  const {_} = useLingui()
   return (
     <Dialog.Outer control={control}>
       <Dialog.Handle />
-      <WhoCanReplyDialogInner post={post} settings={settings} />
+      <Dialog.ScrollableInner
+        label={_(msg`Dialog: adjust who can interact with this post`)}
+        style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
+        <View style={[a.gap_sm]}>
+          <Text style={[a.font_bold, a.text_xl, a.pb_sm]}>
+            <Trans>Who can interact with this post?</Trans>
+          </Text>
+          <Rules
+            post={post}
+            settings={settings}
+            embeddingDisabled={embeddingDisabled}
+          />
+        </View>
+      </Dialog.ScrollableInner>
     </Dialog.Outer>
   )
 }
 
-function WhoCanReplyDialogInner({
-  post,
-  settings,
-}: {
-  post: AppBskyFeedDefs.PostView
-  settings: ThreadgateSetting[]
-}) {
-  const {_} = useLingui()
-  return (
-    <Dialog.ScrollableInner
-      label={_(msg`Who can reply dialog`)}
-      style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
-      <View style={[a.gap_sm]}>
-        <Text style={[a.font_bold, a.text_xl]}>
-          <Trans>Who can reply?</Trans>
-        </Text>
-        <Rules post={post} settings={settings} />
-      </View>
-    </Dialog.ScrollableInner>
-  )
-}
-
 function Rules({
   post,
   settings,
+  embeddingDisabled,
 }: {
   post: AppBskyFeedDefs.PostView
-  settings: ThreadgateSetting[]
+  settings: ThreadgateAllowUISetting[]
+  embeddingDisabled: boolean
 }) {
   const t = useTheme()
+
   return (
-    <Text
-      style={[
-        a.text_md,
-        a.leading_tight,
-        a.flex_wrap,
-        t.atoms.text_contrast_medium,
-      ]}>
-      {!settings.length ? (
-        <Trans>Everybody can reply</Trans>
-      ) : settings[0].type === 'nobody' ? (
-        <Trans>Replies to this thread are disabled</Trans>
-      ) : (
-        <Trans>
-          Only{' '}
-          {settings.map((rule, i) => (
-            <>
-              <Rule
-                key={`rule-${i}`}
-                rule={rule}
-                post={post}
-                lists={post.threadgate!.lists}
-              />
-              <Separator key={`sep-${i}`} i={i} length={settings.length} />
-            </>
-          ))}{' '}
-          can reply
-        </Trans>
+    <>
+      <Text
+        style={[
+          a.text_sm,
+          a.leading_snug,
+          a.flex_wrap,
+          t.atoms.text_contrast_medium,
+        ]}>
+        {settings[0].type === 'everybody' ? (
+          <Trans>Everybody can reply to this post.</Trans>
+        ) : settings[0].type === 'nobody' ? (
+          <Trans>Replies to this post are disabled.</Trans>
+        ) : (
+          <Trans>
+            Only{' '}
+            {settings.map((rule, i) => (
+              <React.Fragment key={`rule-${i}`}>
+                <Rule rule={rule} post={post} lists={post.threadgate!.lists} />
+                <Separator i={i} length={settings.length} />
+              </React.Fragment>
+            ))}{' '}
+            can reply.
+          </Trans>
+        )}{' '}
+      </Text>
+      {embeddingDisabled && (
+        <Text
+          style={[
+            a.text_sm,
+            a.leading_snug,
+            a.flex_wrap,
+            t.atoms.text_contrast_medium,
+          ]}>
+          <Trans>No one but the author can quote this post.</Trans>
+        </Text>
       )}
-    </Text>
+    </>
   )
 }
 
@@ -267,11 +246,10 @@ function Rule({
   post,
   lists,
 }: {
-  rule: ThreadgateSetting
+  rule: ThreadgateAllowUISetting
   post: AppBskyFeedDefs.PostView
   lists: AppBskyGraphDefs.ListViewBasic[] | undefined
 }) {
-  const t = useTheme()
   if (rule.type === 'mention') {
     return <Trans>mentioned users</Trans>
   }
@@ -279,12 +257,12 @@ function Rule({
     return (
       <Trans>
         users followed by{' '}
-        <TextLink
-          type="sm"
-          href={makeProfileLink(post.author)}
-          text={`@${post.author.handle}`}
-          style={{color: t.palette.primary_500}}
-        />
+        <InlineLinkText
+          label={`@${post.author.handle}`}
+          to={makeProfileLink(post.author)}
+          style={[a.text_sm, a.leading_snug]}>
+          @{post.author.handle}
+        </InlineLinkText>
       </Trans>
     )
   }
@@ -294,12 +272,12 @@ function Rule({
       const listUrip = new AtUri(list.uri)
       return (
         <Trans>
-          <TextLink
-            type="sm"
-            href={makeListLink(listUrip.hostname, listUrip.rkey)}
-            text={list.name}
-            style={{color: t.palette.primary_500}}
-          />{' '}
+          <InlineLinkText
+            label={list.name}
+            to={makeListLink(listUrip.hostname, listUrip.rkey)}
+            style={[a.text_sm, a.leading_snug]}>
+            {list.name}
+          </InlineLinkText>{' '}
           members
         </Trans>
       )
@@ -320,20 +298,3 @@ function Separator({i, length}: {i: number; length: number}) {
   }
   return <>, </>
 }
-
-async function whenAppViewReady(
-  agent: BskyAgent,
-  uri: string,
-  fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
-) {
-  await until(
-    5, // 5 tries
-    1e3, // 1s delay between tries
-    fn,
-    () =>
-      agent.app.bsky.feed.getPostThread({
-        uri,
-        depth: 0,
-      }),
-  )
-}
-- 
cgit 1.4.1

