about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2024-06-19 18:39:45 -0700
committerGitHub <noreply@github.com>2024-06-19 18:39:45 -0700
commit80197556f176723b619349dc060d1b7001472d47 (patch)
tree50127b08d99adc95724967d0531194e10b1fbe04 /src
parent75aec19230609f8ae689cd1ec0b2697c5233bdeb (diff)
downloadvoidsky-80197556f176723b619349dc060d1b7001472d47.tar.zst
Rework "Who can reply" to blend more nicely into the UI (#4578)
* Rework WhoCanReply controls in threads to blend more nicely

* Fix layout

* Fix post control hitslops

* Move dialog content to separate component

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/lib/constants.ts1
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx38
-rw-r--r--src/view/com/threadgate/WhoCanReply.tsx411
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx10
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.tsx4
5 files changed, 296 insertions, 168 deletions
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 05d1591f5..e0b899800 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -84,6 +84,7 @@ export const createHitslop = (size: number): Insets => ({
 export const HITSLOP_10 = createHitslop(10)
 export const HITSLOP_20 = createHitslop(20)
 export const HITSLOP_30 = createHitslop(30)
+export const POST_CTRL_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10}
 export const BACK_HITSLOP = HITSLOP_30
 export const MAX_POST_LINES = 25
 
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 6d03029d7..92b529db7 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -25,7 +25,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
 import {countLines} from 'lib/strings/helpers'
 import {niceDate} from 'lib/strings/time'
 import {s} from 'lib/styles'
-import {isNative, isWeb} from 'platform/detection'
+import {isWeb} from 'platform/detection'
 import {useSession} from 'state/session'
 import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
 import {atoms as a} from '#/alf'
@@ -35,7 +35,7 @@ import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
 import {PostAlerts} from '../../../components/moderation/PostAlerts'
 import {PostHider} from '../../../components/moderation/PostHider'
 import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
-import {WhoCanReply} from '../threadgate/WhoCanReply'
+import {WhoCanReplyBlock, WhoCanReplyInline} from '../threadgate/WhoCanReply'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {Link, TextLink} from '../util/Link'
 import {formatCount} from '../util/numeric/format'
@@ -340,6 +340,7 @@ let PostThreadItemLoaded = ({
             </ContentHider>
             <ExpandedPostDetails
               post={post}
+              isThreadAuthor={isThreadAuthor}
               translatorUrl={translatorUrl}
               needsTranslation={needsTranslation}
             />
@@ -396,11 +397,6 @@ let PostThreadItemLoaded = ({
             </View>
           </View>
         </View>
-        <WhoCanReply
-          post={post}
-          isThreadAuthor={isThreadAuthor}
-          style={{borderBottomWidth: isNative ? 1 : 0}}
-        />
       </>
     )
   } else {
@@ -579,14 +575,7 @@ let PostThreadItemLoaded = ({
             ) : undefined}
           </PostHider>
         </PostOuterWrapper>
-        <WhoCanReply
-          post={post}
-          style={{
-            marginTop: 4,
-            borderBottomWidth: 1,
-          }}
-          isThreadAuthor={isThreadAuthor}
-        />
+        <WhoCanReplyBlock post={post} isThreadAuthor={isThreadAuthor} />
       </>
     )
   }
@@ -654,10 +643,12 @@ function PostOuterWrapper({
 
 function ExpandedPostDetails({
   post,
+  isThreadAuthor,
   needsTranslation,
   translatorUrl,
 }: {
   post: AppBskyFeedDefs.PostView
+  isThreadAuthor: boolean
   needsTranslation: boolean
   translatorUrl: string
 }) {
@@ -670,14 +661,23 @@ function ExpandedPostDetails({
   }, [openLink, translatorUrl])
 
   return (
-    <View style={[s.flexRow, s.mt2, s.mb10]}>
-      <Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text>
+    <View
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.flex_wrap,
+        a.gap_sm,
+        s.mt2,
+        s.mb10,
+      ]}>
+      <Text style={[a.text_sm, pal.textLight]}>{niceDate(post.indexedAt)}</Text>
+      <WhoCanReplyInline post={post} isThreadAuthor={isThreadAuthor} />
       {needsTranslation && (
         <>
-          <Text style={pal.textLight}> &middot; </Text>
+          <Text style={[a.text_sm, pal.textLight]}>&middot;</Text>
 
           <Text
-            style={pal.link}
+            style={[a.text_sm, pal.link]}
             title={_(msg`Translate`)}
             onPress={onTranslatePress}>
             <Trans>Translate</Trans>
diff --git a/src/view/com/threadgate/WhoCanReply.tsx b/src/view/com/threadgate/WhoCanReply.tsx
index 7e3528d92..3f9970f5f 100644
--- a/src/view/com/threadgate/WhoCanReply.tsx
+++ b/src/view/com/threadgate/WhoCanReply.tsx
@@ -11,13 +11,10 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
 
-import {useAnalytics} from '#/lib/analytics/analytics'
 import {createThreadgate} from '#/lib/api'
 import {until} from '#/lib/async/until'
-import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
-import {usePalette} from '#/lib/hooks/usePalette'
+import {HITSLOP_10} from '#/lib/constants'
 import {makeListLink, makeProfileLink} from '#/lib/routes/links'
-import {colors} from '#/lib/styles'
 import {logger} from '#/logger'
 import {isNative} from '#/platform/detection'
 import {useModalControls} from '#/state/modals'
@@ -28,97 +25,89 @@ import {
 } 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 {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 {Text} from '#/components/Typography'
 import {TextLink} from '../util/Link'
-import {Text} from '../util/text/Text'
 
-export function WhoCanReply({
-  post,
-  isThreadAuthor,
-  style,
-}: {
+interface WhoCanReplyProps {
   post: AppBskyFeedDefs.PostView
   isThreadAuthor: boolean
   style?: StyleProp<ViewStyle>
-}) {
-  const {track} = useAnalytics()
+}
+
+export function WhoCanReplyInline({
+  post,
+  isThreadAuthor,
+  style,
+}: WhoCanReplyProps) {
   const {_} = useLingui()
-  const pal = usePalette('default')
-  const agent = useAgent()
-  const queryClient = useQueryClient()
-  const {openModal} = useModalControls()
-  const containerStyles = useColorSchemeStyle(
-    {
-      backgroundColor: pal.colors.unreadNotifBg,
-    },
-    {
-      backgroundColor: pal.colors.unreadNotifBg,
-    },
-  )
-  const textStyles = useColorSchemeStyle(
-    {color: colors.blue5},
-    {color: colors.blue1},
-  )
-  const hoverStyles = useColorSchemeStyle(
-    {
-      backgroundColor: colors.white,
-    },
-    {
-      backgroundColor: pal.colors.background,
-    },
-  )
-  const settings = React.useMemo(
-    () => threadgateViewToSettings(post.threadgate),
-    [post],
-  )
-  const isRootPost = !('reply' in post.record)
+  const t = useTheme()
+  const infoDialogControl = useDialogControl()
+  const {settings, isRootPost, onPressEdit} = useWhoCanReply(post)
 
-  const onPressEdit = () => {
-    track('Post:EditThreadgateOpened')
-    if (isNative && Keyboard.isVisible()) {
-      Keyboard.dismiss()
-    }
-    openModal({
-      name: 'threadgate',
-      settings,
-      async onConfirm(newSettings: ThreadgateSetting[]) {
-        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('Thread settings updated')
-          queryClient.invalidateQueries({
-            queryKey: [POST_THREAD_RQKEY_ROOT],
-          })
-          track('Post:ThreadgateEdited')
-        } catch (err) {
-          Toast.show(
-            'There was an issue. Please check your internet connection and try again.',
-          )
-          logger.error('Failed to edit threadgate', {message: err})
-        }
-      },
-    })
+  if (!isRootPost) {
+    return null
+  }
+  if (!settings.length && !isThreadAuthor) {
+    return null
   }
 
+  const isEverybody = settings.length === 0
+  const isNobody = !!settings.find(gate => gate.type === 'nobody')
+  const description = isEverybody
+    ? _(msg`Everybody can reply`)
+    : isNobody
+    ? _(msg`Replies disabled`)
+    : _(msg`Some people can reply`)
+
+  return (
+    <>
+      <Button
+        label={
+          isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
+        }
+        onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open}
+        hitSlop={HITSLOP_10}>
+        {({hovered}) => (
+          <View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
+            <Icon
+              color={t.palette.contrast_400}
+              width={16}
+              settings={settings}
+            />
+            <Text
+              style={[
+                a.text_sm,
+                a.leading_tight,
+                t.atoms.text_contrast_medium,
+                hovered && a.underline,
+              ]}>
+              {description}
+            </Text>
+          </View>
+        )}
+      </Button>
+      <InfoDialog control={infoDialogControl} post={post} settings={settings} />
+    </>
+  )
+}
+
+export function WhoCanReplyBlock({
+  post,
+  isThreadAuthor,
+  style,
+}: WhoCanReplyProps) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const infoDialogControl = useDialogControl()
+  const {settings, isRootPost, onPressEdit} = useWhoCanReply(post)
+
   if (!isRootPost) {
     return null
   }
@@ -126,64 +115,144 @@ export function WhoCanReply({
     return null
   }
 
+  const isEverybody = settings.length === 0
+  const isNobody = !!settings.find(gate => gate.type === 'nobody')
+  const description = isEverybody
+    ? _(msg`Everybody can reply`)
+    : isNobody
+    ? _(msg`Replies on this thread are disabled`)
+    : _(msg`Some people can reply`)
+
   return (
-    <View
-      style={[
-        {
-          flexDirection: 'row',
-          alignItems: 'center',
-          gap: 10,
-          paddingLeft: 18,
-          paddingRight: 14,
-          paddingVertical: 10,
-          borderTopWidth: 1,
-        },
-        pal.border,
-        containerStyles,
-        style,
-      ]}>
-      <View style={{flex: 1, paddingVertical: 6}}>
-        <Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
-          {!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) => (
-                <React.Fragment key={`rule-${i}`}>
-                  <Rule
-                    rule={rule}
-                    post={post}
-                    lists={post.threadgate!.lists}
-                  />
-                  <Separator key={`sep-${i}`} i={i} length={settings.length} />
-                </React.Fragment>
-              ))}{' '}
-              can reply.
-            </Trans>
-          )}
+    <>
+      <Button
+        label={
+          isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
+        }
+        onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open}
+        hitSlop={HITSLOP_10}>
+        {({hovered}) => (
+          <View
+            style={[
+              a.flex_1,
+              a.flex_row,
+              a.align_center,
+              a.py_sm,
+              a.pr_lg,
+              style,
+            ]}>
+            <View style={[{paddingLeft: 25, paddingRight: 18}]}>
+              <Icon color={t.palette.contrast_300} settings={settings} />
+            </View>
+            <Text
+              style={[
+                a.text_sm,
+                a.leading_tight,
+                t.atoms.text_contrast_medium,
+                hovered && a.underline,
+              ]}>
+              {description}
+            </Text>
+          </View>
+        )}
+      </Button>
+      <InfoDialog control={infoDialogControl} post={post} settings={settings} />
+    </>
+  )
+}
+
+function Icon({
+  color,
+  width,
+  settings,
+}: {
+  color: string
+  width?: number
+  settings: ThreadgateSetting[]
+}) {
+  const isEverybody = settings.length === 0
+  const isNobody = !!settings.find(gate => gate.type === 'nobody')
+  const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group
+  return <IconComponent fill={color} width={width} />
+}
+
+function InfoDialog({
+  control,
+  post,
+  settings,
+}: {
+  control: Dialog.DialogControlProps
+  post: AppBskyFeedDefs.PostView
+  settings: ThreadgateSetting[]
+}) {
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+      <InfoDialogInner post={post} settings={settings} />
+    </Dialog.Outer>
+  )
+}
+
+function InfoDialogInner({
+  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>
-      {isThreadAuthor && (
-        <View>
-          <Button label={_(msg`Edit`)} onPress={onPressEdit}>
-            {({hovered}) => (
-              <View
-                style={[
-                  hovered && hoverStyles,
-                  {paddingVertical: 6, paddingHorizontal: 8, borderRadius: 8},
-                ]}>
-                <Text type="sm" style={pal.link}>
-                  <Trans>Edit</Trans>
-                </Text>
-              </View>
-            )}
-          </Button>
-        </View>
+    </Dialog.ScrollableInner>
+  )
+}
+
+function Rules({
+  post,
+  settings,
+}: {
+  post: AppBskyFeedDefs.PostView
+  settings: ThreadgateSetting[]
+}) {
+  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>
       )}
-    </View>
+    </Text>
   )
 }
 
@@ -196,7 +265,7 @@ function Rule({
   post: AppBskyFeedDefs.PostView
   lists: AppBskyGraphDefs.ListViewBasic[] | undefined
 }) {
-  const pal = usePalette('default')
+  const t = useTheme()
   if (rule.type === 'mention') {
     return <Trans>mentioned users</Trans>
   }
@@ -208,7 +277,7 @@ function Rule({
           type="sm"
           href={makeProfileLink(post.author)}
           text={`@${post.author.handle}`}
-          style={pal.link}
+          style={{color: t.palette.primary_500}}
         />
       </Trans>
     )
@@ -223,7 +292,7 @@ function Rule({
             type="sm"
             href={makeListLink(listUrip.hostname, listUrip.rkey)}
             text={list.name}
-            style={pal.link}
+            style={{color: t.palette.primary_500}}
           />{' '}
           members
         </Trans>
@@ -246,6 +315,64 @@ function Separator({i, length}: {i: number; length: number}) {
   return <>, </>
 }
 
+function useWhoCanReply(post: AppBskyFeedDefs.PostView) {
+  const agent = useAgent()
+  const queryClient = useQueryClient()
+  const {openModal} = useModalControls()
+
+  const settings = React.useMemo(
+    () => threadgateViewToSettings(post.threadgate),
+    [post],
+  )
+  const isRootPost = !('reply' in post.record)
+
+  const onPressEdit = () => {
+    if (isNative && Keyboard.isVisible()) {
+      Keyboard.dismiss()
+    }
+    openModal({
+      name: 'threadgate',
+      settings,
+      async onConfirm(newSettings: ThreadgateSetting[]) {
+        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('Thread settings updated')
+          queryClient.invalidateQueries({
+            queryKey: [POST_THREAD_RQKEY_ROOT],
+          })
+        } catch (err) {
+          Toast.show(
+            'There was an issue. Please check your internet connection and try again.',
+          )
+          logger.error('Failed to edit threadgate', {message: err})
+        }
+      },
+    })
+  }
+
+  return {settings, isRootPost, onPressEdit}
+}
+
 async function whenAppViewReady(
   agent: BskyAgent,
   uri: string,
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 55fb4a334..472ce4043 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -15,7 +15,7 @@ import {
 import {msg, plural} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {HITSLOP_10, HITSLOP_20} from '#/lib/constants'
+import {POST_CTRL_HITSLOP} from '#/lib/constants'
 import {useHaptics} from '#/lib/haptics'
 import {makeProfileLink} from '#/lib/routes/links'
 import {shareUrl} from '#/lib/sharing'
@@ -215,7 +215,7 @@ let PostCtrls = ({
             other: 'Reply (# replies)',
           })}
           accessibilityHint=""
-          hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
+          hitSlop={POST_CTRL_HITSLOP}>
           <Bubble
             style={[defaultCtrlColor, {pointerEvents: 'none'}]}
             width={big ? 22 : 18}
@@ -258,7 +258,7 @@ let PostCtrls = ({
                 })
           }
           accessibilityHint=""
-          hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
+          hitSlop={POST_CTRL_HITSLOP}>
           {post.viewer?.like ? (
             <HeartIconFilled style={s.likeColor} width={big ? 22 : 18} />
           ) : (
@@ -299,7 +299,7 @@ let PostCtrls = ({
               }}
               accessibilityLabel={_(msg`Share`)}
               accessibilityHint=""
-              hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
+              hitSlop={POST_CTRL_HITSLOP}>
               <ArrowOutOfBox
                 style={[defaultCtrlColor, {pointerEvents: 'none'}]}
                 width={22}
@@ -325,7 +325,7 @@ let PostCtrls = ({
           record={record}
           richText={richText}
           style={{padding: 5}}
-          hitSlop={big ? HITSLOP_20 : HITSLOP_10}
+          hitSlop={POST_CTRL_HITSLOP}
           timestamp={post.indexedAt}
         />
       </View>
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index 10bc369b8..d49cda442 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -3,7 +3,7 @@ import {View} from 'react-native'
 import {msg, plural} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {HITSLOP_10, HITSLOP_20} from '#/lib/constants'
+import {POST_CTRL_HITSLOP} from '#/lib/constants'
 import {useHaptics} from '#/lib/haptics'
 import {useRequireAuth} from '#/state/session'
 import {atoms as a, useTheme} from '#/alf'
@@ -67,7 +67,7 @@ let RepostButton = ({
         shape="round"
         variant="ghost"
         color="secondary"
-        hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
+        hitSlop={POST_CTRL_HITSLOP}>
         <Repost style={color} width={big ? 22 : 18} />
         {typeof repostCount !== 'undefined' && repostCount > 0 ? (
           <Text