about summary refs log tree commit diff
path: root/src/components/WhoCanReply.tsx
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2024-06-24 10:11:43 -0700
committerGitHub <noreply@github.com>2024-06-24 10:11:43 -0700
commitf769564edfea3ec6406c49ef639685d942e14e09 (patch)
treea21a28a4e5798a6ba52c18f4580c48a6593937cd /src/components/WhoCanReply.tsx
parent0a0c7387905c7dc61fefd4f5f27d53b4797c00f6 (diff)
downloadvoidsky-f769564edfea3ec6406c49ef639685d942e14e09.tar.zst
Remove the 'Who can reply' element except when viewing root, and add "edit" (#4615)
* Remove the 'Who can reply' element except when viewing root, and add the edit text to authors

* Switch to icon
Diffstat (limited to 'src/components/WhoCanReply.tsx')
-rw-r--r--src/components/WhoCanReply.tsx324
1 files changed, 324 insertions, 0 deletions
diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx
new file mode 100644
index 000000000..cd171a0a4
--- /dev/null
+++ b/src/components/WhoCanReply.tsx
@@ -0,0 +1,324 @@
+import React from 'react'
+import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedGetPostThread,
+  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 {useModalControls} from '#/state/modals'
+import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
+import {
+  ThreadgateSetting,
+  threadgateViewToSettings,
+} 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 '../view/com/util/Link'
+import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
+
+interface WhoCanReplyProps {
+  post: AppBskyFeedDefs.PostView
+  isThreadAuthor: boolean
+  style?: StyleProp<ViewStyle>
+}
+
+export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const infoDialogControl = useDialogControl()
+  const {settings, isRootPost, onPressEdit} = useWhoCanReply(post)
+
+  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>
+            {isThreadAuthor && (
+              <PencilLine width={12} fill={t.palette.primary_500} />
+            )}
+          </View>
+        )}
+      </Button>
+      <WhoCanReplyDialog control={infoDialogControl} post={post} />
+    </>
+  )
+}
+
+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} />
+}
+
+export function WhoCanReplyDialog({
+  control,
+  post,
+}: {
+  control: Dialog.DialogControlProps
+  post: AppBskyFeedDefs.PostView
+}) {
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+      <WhoCanReplyDialogInner post={post} />
+    </Dialog.Outer>
+  )
+}
+
+function WhoCanReplyDialogInner({post}: {post: AppBskyFeedDefs.PostView}) {
+  const {_} = useLingui()
+  const {settings} = useWhoCanReply(post)
+  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,
+}: {
+  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>
+      )}
+    </Text>
+  )
+}
+
+function Rule({
+  rule,
+  post,
+  lists,
+}: {
+  rule: ThreadgateSetting
+  post: AppBskyFeedDefs.PostView
+  lists: AppBskyGraphDefs.ListViewBasic[] | undefined
+}) {
+  const t = useTheme()
+  if (rule.type === 'mention') {
+    return <Trans>mentioned users</Trans>
+  }
+  if (rule.type === 'following') {
+    return (
+      <Trans>
+        users followed by{' '}
+        <TextLink
+          type="sm"
+          href={makeProfileLink(post.author)}
+          text={`@${post.author.handle}`}
+          style={{color: t.palette.primary_500}}
+        />
+      </Trans>
+    )
+  }
+  if (rule.type === 'list') {
+    const list = lists?.find(l => l.uri === rule.list)
+    if (list) {
+      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}}
+          />{' '}
+          members
+        </Trans>
+      )
+    }
+  }
+}
+
+function Separator({i, length}: {i: number; length: number}) {
+  if (length < 2 || i === length - 1) {
+    return null
+  }
+  if (i === length - 2) {
+    return (
+      <>
+        {length > 2 ? ',' : ''} <Trans>and</Trans>{' '}
+      </>
+    )
+  }
+  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[]) {
+        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('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,
+  fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
+) {
+  await until(
+    5, // 5 tries
+    1e3, // 1s delay between tries
+    fn,
+    () =>
+      agent.app.bsky.feed.getPostThread({
+        uri,
+        depth: 0,
+      }),
+  )
+}