diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx new file mode 100644 index 00000000..e5b747cc --- /dev/null +++ b/src/view/com/post-thread/PostThreadFollowBtn.tsx @@ -0,0 +1,154 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {useNavigation} from '@react-navigation/native' +import {AppBskyActorDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' + +import {logger} from '#/logger' +import {Text} from 'view/com/util/text/Text' +import * as Toast from 'view/com/util/Toast' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Shadow, useProfileShadow} from 'state/cache/profile-shadow' +import {track} from 'lib/analytics/analytics' +import { + useProfileFollowMutationQueue, + useProfileQuery, +} from 'state/queries/profile' +import {useRequireAuth} from 'state/session' + +export function PostThreadFollowBtn({did}: {did: string}) { + const {data: profile, isLoading} = useProfileQuery({did}) + + // We will never hit this - the profile will always be cached or loaded above + // but it keeps the typechecker happy + if (isLoading || !profile) return null + + return +} + +function PostThreadFollowBtnLoaded({ + profile: profileUnshadowed, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed +}) { + const navigation = useNavigation() + const {_} = useLingui() + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const {isTabletOrDesktop} = useWebMediaQueries() + const profile: Shadow = + useProfileShadow(profileUnshadowed) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) + const requireAuth = useRequireAuth() + + const isFollowing = !!profile.viewer?.following + const [wasFollowing, setWasFollowing] = React.useState(isFollowing) + + // This prevents the button from disappearing as soon as we follow. + const showFollowBtn = React.useMemo( + () => !isFollowing || !wasFollowing, + [isFollowing, wasFollowing], + ) + + /** + * We want this button to stay visible even after following, so that the user can unfollow if they want. + * However, we need it to disappear after we push to a screen and then come back. We also need it to + * show up if we view the post while following, go to the profile and unfollow, then come back to the + * post. + * + * We want to update wasFollowing both on blur and on focus so that we hit all these cases. On native, + * we could do this only on focus because the transition animation gives us time to not notice the + * sudden rendering of the button. However, on web if we do this, there's an obvious flicker once the + * button renders. So, we update the state in both cases. + */ + React.useEffect(() => { + const updateWasFollowing = () => { + if (wasFollowing !== isFollowing) { + setWasFollowing(isFollowing) + } + } + + const unsubscribeFocus = navigation.addListener('focus', updateWasFollowing) + const unsubscribeBlur = navigation.addListener('blur', updateWasFollowing) + + return () => { + unsubscribeFocus() + unsubscribeBlur() + } + }, [isFollowing, wasFollowing, navigation]) + + const onPress = React.useCallback(() => { + if (!isFollowing) { + requireAuth(async () => { + try { + track('ProfileHeader:FollowButtonClicked') + await queueFollow() + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to follow', {message: String(e)}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + }) + } else { + requireAuth(async () => { + try { + track('ProfileHeader:UnfollowButtonClicked') + await queueUnfollow() + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unfollow', {message: String(e)}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + }) + } + }, [isFollowing, requireAuth, queueFollow, _, queueUnfollow]) + + if (!showFollowBtn) return null + + return ( + + + + {isTabletOrDesktop && ( + + )} + + {!isFollowing ? Follow : Following} + + + + + ) +} + +const styles = StyleSheet.create({ + btnOuter: { + marginLeft: 'auto', + }, + btn: { + flexDirection: 'row', + borderRadius: 50, + paddingVertical: 8, + paddingHorizontal: 14, + }, +}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index d3ca6f35..17528b14 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -9,6 +9,7 @@ import { } from '@atproto/api' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' import {Link, TextLink} from '../util/Link' import {RichText} from '../util/text/RichText' import {Text} from '../util/text/Text' @@ -30,7 +31,6 @@ import {PostSandboxWarning} from '../util/PostSandboxWarning' import {ErrorMessage} from '../util/error/ErrorMessage' import {usePalette} from 'lib/hooks/usePalette' import {formatCount} from '../util/numeric/format' -import {TimeElapsed} from 'view/com/util/TimeElapsed' import {makeProfileLink} from 'lib/routes/links' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {MAX_POST_LINES} from 'lib/constants' @@ -42,6 +42,7 @@ import {useModerationOpts} from '#/state/queries/preferences' import {useOpenLink} from '#/state/preferences/in-app-browser' import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' import {ThreadPost} from '#/state/queries/post-thread' +import {useSession} from 'state/session' import {WhoCanReply} from '../threadgate/WhoCanReply' export function PostThreadItem({ @@ -113,7 +114,6 @@ export function PostThreadItem({ } function PostThreadItemDeleted() { - const styles = useStyles() const pal = usePalette('default') return ( @@ -163,7 +163,7 @@ let PostThreadItemLoaded = ({ const [limitLines, setLimitLines] = React.useState( () => countLines(richText?.text) >= MAX_POST_LINES, ) - const styles = useStyles() + const {currentAccount} = useSession() const hasEngagement = post.likeCount || post.repostCount const rootUri = record.reply?.root?.uri || post.uri @@ -249,7 +249,7 @@ let PostThreadItemLoaded = ({ style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} accessible={false}> - + - - - - {sanitizeDisplayName( - post.author.displayName || - sanitizeHandle(post.author.handle), - )} - - - - {({timeElapsed}) => ( - - · {timeElapsed} - + + + {sanitizeDisplayName( + post.author.displayName || + sanitizeHandle(post.author.handle), )} - - + + {isAuthorMuted && ( @@ -315,16 +300,16 @@ let PostThreadItemLoaded = ({ )} - + {sanitizeHandle(post.author.handle, '@')} + {currentAccount?.did !== post.author.did && ( + + )} ) { const {isMobile} = useWebMediaQueries() const pal = usePalette('default') - const styles = useStyles() if (treeView && depth > 0) { return ( { - const {isDesktop} = useWebMediaQueries() - return StyleSheet.create({ - outer: { - borderTopWidth: 1, - paddingLeft: 8, - }, - outerHighlighted: { - paddingTop: 16, - paddingLeft: 8, - paddingRight: 8, - }, - noTopBorder: { - borderTopWidth: 0, - }, - layout: { - flexDirection: 'row', - gap: 10, - paddingLeft: 8, - }, - layoutAvi: {}, - layoutContent: { - flex: 1, - paddingRight: 10, - }, - meta: { - flexDirection: 'row', - paddingTop: 2, - paddingBottom: 2, - }, - metaExpandedLine1: { - paddingTop: 0, - paddingBottom: 0, - }, - metaItem: { - paddingRight: 5, - maxWidth: isDesktop ? 380 : 220, - }, - alert: { - marginBottom: 6, - }, - postTextContainer: { - flexDirection: 'row', - alignItems: 'center', - flexWrap: 'wrap', - paddingBottom: 4, - paddingRight: 10, - }, - postTextLargeContainer: { - paddingHorizontal: 0, - paddingRight: 0, - paddingBottom: 10, - }, - translateLink: { - marginBottom: 6, - }, - contentHider: { - marginBottom: 6, - }, - contentHiderChild: { - marginTop: 6, - }, - expandedInfo: { - flexDirection: 'row', - padding: 10, - borderTopWidth: 1, - borderBottomWidth: 1, - marginTop: 5, - marginBottom: 15, - }, - expandedInfoItem: { - marginRight: 10, - }, - loadMore: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-start', - gap: 4, - paddingHorizontal: 20, - }, - replyLine: { - width: 2, - marginLeft: 'auto', - marginRight: 'auto', - }, - cursor: { - // @ts-ignore web only - cursor: 'pointer', - }, - }) -} +const styles = StyleSheet.create({ + outer: { + borderTopWidth: 1, + paddingLeft: 8, + }, + outerHighlighted: { + paddingTop: 16, + paddingLeft: 8, + paddingRight: 8, + }, + noTopBorder: { + borderTopWidth: 0, + }, + layout: { + flexDirection: 'row', + paddingHorizontal: 8, + }, + layoutAvi: {}, + layoutContent: { + flex: 1, + marginLeft: 10, + }, + meta: { + flexDirection: 'row', + paddingVertical: 2, + }, + metaExpandedLine1: { + paddingVertical: 0, + }, + alert: { + marginBottom: 6, + }, + postTextContainer: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + paddingBottom: 4, + paddingRight: 10, + }, + postTextLargeContainer: { + paddingHorizontal: 0, + paddingRight: 0, + paddingBottom: 10, + }, + translateLink: { + marginBottom: 6, + }, + contentHider: { + marginBottom: 6, + }, + contentHiderChild: { + marginTop: 6, + }, + expandedInfo: { + flexDirection: 'row', + padding: 10, + borderTopWidth: 1, + borderBottomWidth: 1, + marginTop: 5, + marginBottom: 15, + }, + expandedInfoItem: { + marginRight: 10, + }, + loadMore: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + gap: 4, + paddingHorizontal: 20, + }, + replyLine: { + width: 2, + marginLeft: 'auto', + marginRight: 'auto', + }, + cursor: { + // @ts-ignore web only + cursor: 'pointer', + }, +})