about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/lib/analytics/types.ts2
-rw-r--r--src/lib/api/index.ts17
-rw-r--r--src/state/modals/index.tsx3
-rw-r--r--src/state/queries/post-thread.ts2
-rw-r--r--src/state/queries/threadgate.ts33
-rw-r--r--src/view/com/modals/Threadgate.tsx11
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx25
-rw-r--r--src/view/com/threadgate/WhoCanReply.tsx234
8 files changed, 219 insertions, 108 deletions
diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts
index cdf535dec..720495ea1 100644
--- a/src/lib/analytics/types.ts
+++ b/src/lib/analytics/types.ts
@@ -32,6 +32,8 @@ export type TrackPropertiesMap = {
   'Post:ThreadMute': {} // CAN BE SERVER
   'Post:ThreadUnmute': {} // CAN BE SERVER
   'Post:Reply': {} // CAN BE SERVER
+  'Post:EditThreadgateOpened': {}
+  'Post:ThreadgateEdited': {}
   // PROFILE events
   'Profile:Follow': {
     username: string
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index dfaae2e01..5b1c998cb 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -270,7 +270,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
   return res
 }
 
-async function createThreadgate(
+export async function createThreadgate(
   agent: BskyAgent,
   postUri: string,
   threadgate: ThreadgateSetting[],
@@ -296,10 +296,17 @@ async function createThreadgate(
   }
 
   const postUrip = new AtUri(postUri)
-  await agent.api.app.bsky.feed.threadgate.create(
-    {repo: agent.session!.did, rkey: postUrip.rkey},
-    {post: postUri, createdAt: new Date().toISOString(), allow},
-  )
+  await agent.api.com.atproto.repo.putRecord({
+    repo: agent.session!.did,
+    collection: 'app.bsky.feed.threadgate',
+    rkey: postUrip.rkey,
+    record: {
+      $type: 'app.bsky.feed.threadgate',
+      post: postUri,
+      allow,
+      createdAt: new Date().toISOString(),
+    },
+  })
 }
 
 // helpers
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index ced14335b..685b10bd8 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -70,7 +70,8 @@ export interface SelfLabelModal {
 export interface ThreadgateModal {
   name: 'threadgate'
   settings: ThreadgateSetting[]
-  onChange: (settings: ThreadgateSetting[]) => void
+  onChange?: (settings: ThreadgateSetting[]) => void
+  onConfirm?: (settings: ThreadgateSetting[]) => void
 }
 
 export interface ChangeHandleModal {
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index f7e5e2ecb..db85e8a17 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -32,7 +32,7 @@ import {
 } from './util'
 
 const REPLY_TREE_DEPTH = 10
-const RQKEY_ROOT = 'post-thread'
+export const RQKEY_ROOT = 'post-thread'
 export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
 type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
 
diff --git a/src/state/queries/threadgate.ts b/src/state/queries/threadgate.ts
index 489117582..67c6f8c08 100644
--- a/src/state/queries/threadgate.ts
+++ b/src/state/queries/threadgate.ts
@@ -1,5 +1,38 @@
+import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
+
 export type ThreadgateSetting =
   | {type: 'nobody'}
   | {type: 'mention'}
   | {type: 'following'}
   | {type: 'list'; list: string}
+
+export function threadgateViewToSettings(
+  threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
+): ThreadgateSetting[] {
+  const record =
+    threadgate &&
+    AppBskyFeedThreadgate.isRecord(threadgate.record) &&
+    AppBskyFeedThreadgate.validateRecord(threadgate.record).success
+      ? threadgate.record
+      : null
+  if (!record) {
+    return []
+  }
+  if (!record.allow?.length) {
+    return [{type: 'nobody'}]
+  }
+  return record.allow
+    .map(allow => {
+      if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
+        return {type: 'mention'}
+      }
+      if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
+        return {type: 'following'}
+      }
+      if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
+        return {type: 'list', list: allow.list}
+      }
+      return undefined
+    })
+    .filter(Boolean) as ThreadgateSetting[]
+}
diff --git a/src/view/com/modals/Threadgate.tsx b/src/view/com/modals/Threadgate.tsx
index a2e9f391c..4a9a9e2ab 100644
--- a/src/view/com/modals/Threadgate.tsx
+++ b/src/view/com/modals/Threadgate.tsx
@@ -26,9 +26,11 @@ export const snapPoints = ['60%']
 export function Component({
   settings,
   onChange,
+  onConfirm,
 }: {
   settings: ThreadgateSetting[]
-  onChange: (settings: ThreadgateSetting[]) => void
+  onChange?: (settings: ThreadgateSetting[]) => void
+  onConfirm?: (settings: ThreadgateSetting[]) => void
 }) {
   const pal = usePalette('default')
   const {closeModal} = useModalControls()
@@ -38,12 +40,12 @@ export function Component({
 
   const onPressEverybody = () => {
     setSelected([])
-    onChange([])
+    onChange?.([])
   }
 
   const onPressNobody = () => {
     setSelected([{type: 'nobody'}])
-    onChange([{type: 'nobody'}])
+    onChange?.([{type: 'nobody'}])
   }
 
   const onPressAudience = (setting: ThreadgateSetting) => {
@@ -57,7 +59,7 @@ export function Component({
       newSelected.splice(i, 1)
     }
     setSelected(newSelected)
-    onChange(newSelected)
+    onChange?.(newSelected)
   }
 
   return (
@@ -124,6 +126,7 @@ export function Component({
           testID="confirmBtn"
           onPress={() => {
             closeModal()
+            onConfirm?.(selected)
           }}
           style={styles.btn}
           accessibilityRole="button"
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 5ee60e4ea..6d03029d7 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 {isWeb} from 'platform/detection'
+import {isNative, isWeb} from 'platform/detection'
 import {useSession} from 'state/session'
 import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
 import {atoms as a} from '#/alf'
@@ -189,6 +189,7 @@ let PostThreadItemLoaded = ({
   const itemTitle = _(msg`Post by ${post.author.handle}`)
   const authorHref = makeProfileLink(post.author)
   const authorTitle = post.author.handle
+  const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
   const likesHref = React.useMemo(() => {
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
@@ -395,7 +396,11 @@ let PostThreadItemLoaded = ({
             </View>
           </View>
         </View>
-        <WhoCanReply post={post} />
+        <WhoCanReply
+          post={post}
+          isThreadAuthor={isThreadAuthor}
+          style={{borderBottomWidth: isNative ? 1 : 0}}
+        />
       </>
     )
   } else {
@@ -578,7 +583,9 @@ let PostThreadItemLoaded = ({
           post={post}
           style={{
             marginTop: 4,
+            borderBottomWidth: 1,
           }}
+          isThreadAuthor={isThreadAuthor}
         />
       </>
     )
@@ -681,6 +688,20 @@ function ExpandedPostDetails({
   )
 }
 
+function getThreadAuthor(
+  post: AppBskyFeedDefs.PostView,
+  record: AppBskyFeedPost.Record,
+): string {
+  if (!record.reply) {
+    return post.author.did
+  }
+  try {
+    return new AtUri(record.reply.root.uri).host
+  } catch {
+    return ''
+  }
+}
+
 const styles = StyleSheet.create({
   outer: {
     borderTopWidth: hairlineWidth,
diff --git a/src/view/com/threadgate/WhoCanReply.tsx b/src/view/com/threadgate/WhoCanReply.tsx
index c1e36d481..3ffbaa7ae 100644
--- a/src/view/com/threadgate/WhoCanReply.tsx
+++ b/src/view/com/threadgate/WhoCanReply.tsx
@@ -1,128 +1,172 @@
 import React from 'react'
-import {StyleProp, View, ViewStyle} from 'react-native'
-import {
-  AppBskyFeedDefs,
-  AppBskyFeedThreadgate,
-  AppBskyGraphDefs,
-  AtUri,
-} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Trans} from '@lingui/macro'
+import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
+import {AppBskyFeedDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
+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 {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
 import {usePalette} from '#/lib/hooks/usePalette'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 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'
+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 {Button} from '#/components/Button'
 import {TextLink} from '../util/Link'
 import {Text} from '../util/text/Text'
 
 export function WhoCanReply({
   post,
+  isThreadAuthor,
   style,
 }: {
   post: AppBskyFeedDefs.PostView
+  isThreadAuthor: boolean
   style?: StyleProp<ViewStyle>
 }) {
+  const {track} = useAnalytics()
+  const {_} = useLingui()
   const pal = usePalette('default')
-  const {isMobile} = useWebMediaQueries()
+  const agent = useAgent()
+  const queryClient = useQueryClient()
+  const {openModal} = useModalControls()
   const containerStyles = useColorSchemeStyle(
     {
-      borderColor: pal.colors.unreadNotifBorder,
       backgroundColor: pal.colors.unreadNotifBg,
     },
     {
-      borderColor: pal.colors.unreadNotifBorder,
       backgroundColor: pal.colors.unreadNotifBg,
     },
   )
-  const iconStyles = useColorSchemeStyle(
+  const textStyles = useColorSchemeStyle(
+    {color: colors.blue5},
+    {color: colors.blue1},
+  )
+  const hoverStyles = useColorSchemeStyle(
     {
-      backgroundColor: colors.blue3,
+      backgroundColor: colors.white,
     },
     {
-      backgroundColor: colors.blue3,
+      backgroundColor: pal.colors.background,
     },
   )
-  const textStyles = useColorSchemeStyle(
-    {color: colors.gray7},
-    {color: colors.blue1},
-  )
-  const record = React.useMemo(
-    () =>
-      post.threadgate &&
-      AppBskyFeedThreadgate.isRecord(post.threadgate.record) &&
-      AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success
-        ? post.threadgate.record
-        : null,
+  const settings = React.useMemo(
+    () => threadgateViewToSettings(post.threadgate),
     [post],
   )
-  if (record) {
-    return (
-      <View
-        style={[
-          {
-            flexDirection: 'row',
-            alignItems: 'center',
-            gap: isMobile ? 8 : 10,
-            paddingHorizontal: isMobile ? 16 : 18,
-            paddingVertical: 12,
-            borderWidth: 1,
-            borderLeftWidth: isMobile ? 0 : 1,
-            borderRightWidth: isMobile ? 0 : 1,
-          },
-          containerStyles,
-          style,
-        ]}>
-        <View
-          style={[
-            {
-              flexDirection: 'row',
-              alignItems: 'center',
-              justifyContent: 'center',
-              width: 32,
-              height: 32,
-              borderRadius: 19,
-            },
-            iconStyles,
-          ]}>
-          <FontAwesomeIcon
-            icon={['far', 'comments']}
-            size={16}
-            color={'#fff'}
-          />
-        </View>
-        <View style={{flex: 1}}>
-          <Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
-            {!record.allow?.length ? (
-              <Trans>Replies to this thread are disabled</Trans>
-            ) : (
-              <Trans>
-                Only{' '}
-                {record.allow.map((rule, i) => (
-                  <>
-                    <Rule
-                      key={`rule-${i}`}
-                      rule={rule}
-                      post={post}
-                      lists={post.threadgate!.lists}
-                    />
-                    <Separator
-                      key={`sep-${i}`}
-                      i={i}
-                      length={record.allow!.length}
-                    />
-                  </>
-                ))}{' '}
-                can reply.
-              </Trans>
+  const isRootPost = !('reply' in post.record)
+
+  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,
+            })
+          }
+          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
+  }
+
+  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) => (
+                <>
+                  <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>
+      </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>
             )}
-          </Text>
+          </Button>
         </View>
-      </View>
-    )
-  }
-  return null
+      )}
+    </View>
+  )
 }
 
 function Rule({
@@ -130,15 +174,15 @@ function Rule({
   post,
   lists,
 }: {
-  rule: any
+  rule: ThreadgateSetting
   post: AppBskyFeedDefs.PostView
   lists: AppBskyGraphDefs.ListViewBasic[] | undefined
 }) {
   const pal = usePalette('default')
-  if (AppBskyFeedThreadgate.isMentionRule(rule)) {
+  if (rule.type === 'mention') {
     return <Trans>mentioned users</Trans>
   }
-  if (AppBskyFeedThreadgate.isFollowingRule(rule)) {
+  if (rule.type === 'following') {
     return (
       <Trans>
         users followed by{' '}
@@ -151,7 +195,7 @@ function Rule({
       </Trans>
     )
   }
-  if (AppBskyFeedThreadgate.isListRule(rule)) {
+  if (rule.type === 'list') {
     const list = lists?.find(l => l.uri === rule.list)
     if (list) {
       const listUrip = new AtUri(list.uri)