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',
+ },
+})