Implement thread locking (#4545)
* Add the ability to edit threadgates * Fix bottom border on mobile * Refresh thread after threadgate edit
This commit is contained in:
		
							parent
							
								
									4165a02b2d
								
							
						
					
					
						commit
						d6ce16d15a
					
				
					 8 changed files with 222 additions and 111 deletions
				
			
		| 
						 | 
				
			
			@ -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"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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(
 | 
			
		||||
    {
 | 
			
		||||
      backgroundColor: colors.blue3,
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      backgroundColor: colors.blue3,
 | 
			
		||||
    },
 | 
			
		||||
  )
 | 
			
		||||
  const textStyles = useColorSchemeStyle(
 | 
			
		||||
    {color: colors.gray7},
 | 
			
		||||
    {color: colors.blue5},
 | 
			
		||||
    {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 hoverStyles = useColorSchemeStyle(
 | 
			
		||||
    {
 | 
			
		||||
      backgroundColor: colors.white,
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      backgroundColor: pal.colors.background,
 | 
			
		||||
    },
 | 
			
		||||
  )
 | 
			
		||||
  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>
 | 
			
		||||
            )}
 | 
			
		||||
          </Text>
 | 
			
		||||
        </View>
 | 
			
		||||
      </View>
 | 
			
		||||
    )
 | 
			
		||||
  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})
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
  return null
 | 
			
		||||
 | 
			
		||||
  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>
 | 
			
		||||
            )}
 | 
			
		||||
          </Button>
 | 
			
		||||
        </View>
 | 
			
		||||
      )}
 | 
			
		||||
    </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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue