Add follow button to highlighted post (#2828)

* add user-minus icon

* add follow button to highlighted post

* web hack for animations

* adjustments

* remove static string width, use flexbox

* Revert "add user-minus icon"

This reverts commit f1aafb3e39dce131b729864924d63a22368f9187.

* better displaying of display name
zio/stable
Hailey 2024-02-12 11:47:22 -08:00 committed by GitHub
parent a97d469981
commit fe57335b86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 254 additions and 126 deletions

View File

@ -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 <PostThreadFollowBtnLoaded profile={profile} />
}
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<AppBskyActorDefs.ProfileViewBasic> =
useProfileShadow(profileUnshadowed)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
const requireAuth = useRequireAuth()
const isFollowing = !!profile.viewer?.following
const [wasFollowing, setWasFollowing] = React.useState<boolean>(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 (
<View style={{width: isTabletOrDesktop ? 130 : 120}}>
<View style={styles.btnOuter}>
<TouchableOpacity
testID="followBtn"
onPress={onPress}
style={[styles.btn, !isFollowing ? palInverted.view : pal.viewLight]}
accessibilityRole="button"
accessibilityLabel={_(msg`Follow ${profile.handle}`)}
accessibilityHint={_(
msg`Shows posts from ${profile.handle} in your feed`,
)}>
{isTabletOrDesktop && (
<FontAwesomeIcon
icon={!isFollowing ? 'plus' : 'check'}
style={[!isFollowing ? palInverted.text : pal.text, s.mr5]}
/>
)}
<Text
type="button"
style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
numberOfLines={1}>
{!isFollowing ? <Trans>Follow</Trans> : <Trans>Following</Trans>}
</Text>
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
btnOuter: {
marginLeft: 'auto',
},
btn: {
flexDirection: 'row',
borderRadius: 50,
paddingVertical: 8,
paddingHorizontal: 14,
},
})

View File

@ -9,6 +9,7 @@ import {
} from '@atproto/api' } from '@atproto/api'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {Link, TextLink} from '../util/Link' import {Link, TextLink} from '../util/Link'
import {RichText} from '../util/text/RichText' import {RichText} from '../util/text/RichText'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
@ -30,7 +31,6 @@ import {PostSandboxWarning} from '../util/PostSandboxWarning'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {formatCount} from '../util/numeric/format' import {formatCount} from '../util/numeric/format'
import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {MAX_POST_LINES} from 'lib/constants' 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 {useOpenLink} from '#/state/preferences/in-app-browser'
import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
import {ThreadPost} from '#/state/queries/post-thread' import {ThreadPost} from '#/state/queries/post-thread'
import {useSession} from 'state/session'
import {WhoCanReply} from '../threadgate/WhoCanReply' import {WhoCanReply} from '../threadgate/WhoCanReply'
export function PostThreadItem({ export function PostThreadItem({
@ -113,7 +114,6 @@ export function PostThreadItem({
} }
function PostThreadItemDeleted() { function PostThreadItemDeleted() {
const styles = useStyles()
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
<View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}> <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
@ -163,7 +163,7 @@ let PostThreadItemLoaded = ({
const [limitLines, setLimitLines] = React.useState( const [limitLines, setLimitLines] = React.useState(
() => countLines(richText?.text) >= MAX_POST_LINES, () => countLines(richText?.text) >= MAX_POST_LINES,
) )
const styles = useStyles() const {currentAccount} = useSession()
const hasEngagement = post.likeCount || post.repostCount const hasEngagement = post.likeCount || post.repostCount
const rootUri = record.reply?.root?.uri || post.uri const rootUri = record.reply?.root?.uri || post.uri
@ -249,7 +249,7 @@ let PostThreadItemLoaded = ({
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
accessible={false}> accessible={false}>
<PostSandboxWarning /> <PostSandboxWarning />
<View style={styles.layout}> <View style={[styles.layout]}>
<View style={[styles.layoutAvi, {paddingBottom: 8}]}> <View style={[styles.layoutAvi, {paddingBottom: 8}]}>
<PreviewableUserAvatar <PreviewableUserAvatar
size={42} size={42}
@ -262,33 +262,18 @@ let PostThreadItemLoaded = ({
<View style={styles.layoutContent}> <View style={styles.layoutContent}>
<View <View
style={[styles.meta, styles.metaExpandedLine1, {zIndex: 1}]}> style={[styles.meta, styles.metaExpandedLine1, {zIndex: 1}]}>
<View style={[s.flexRow]}> <Link style={s.flex1} href={authorHref} title={authorTitle}>
<Link <Text
style={styles.metaItem} type="xl-bold"
href={authorHref} style={[pal.text]}
title={authorTitle}> numberOfLines={1}
<Text lineHeight={1.2}>
type="xl-bold" {sanitizeDisplayName(
style={[pal.text]} post.author.displayName ||
numberOfLines={1} sanitizeHandle(post.author.handle),
lineHeight={1.2}>
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
)}
</Text>
</Link>
<TimeElapsed timestamp={post.indexedAt}>
{({timeElapsed}) => (
<Text
type="md"
style={[styles.metaItem, pal.textLight]}
title={niceDate(post.indexedAt)}>
&middot;&nbsp;{timeElapsed}
</Text>
)} )}
</TimeElapsed> </Text>
</View> </Link>
</View> </View>
<View style={styles.meta}> <View style={styles.meta}>
{isAuthorMuted && ( {isAuthorMuted && (
@ -315,16 +300,16 @@ let PostThreadItemLoaded = ({
</Text> </Text>
</View> </View>
)} )}
<Link <Link style={s.flex1} href={authorHref} title={authorTitle}>
style={styles.metaItem}
href={authorHref}
title={authorTitle}>
<Text type="md" style={[pal.textLight]} numberOfLines={1}> <Text type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(post.author.handle, '@')} {sanitizeHandle(post.author.handle, '@')}
</Text> </Text>
</Link> </Link>
</View> </View>
</View> </View>
{currentAccount?.did !== post.author.did && (
<PostThreadFollowBtn did={post.author.did} />
)}
</View> </View>
<View style={[s.pl10, s.pr10, s.pb10]}> <View style={[s.pl10, s.pr10, s.pb10]}>
<ContentHider <ContentHider
@ -626,7 +611,6 @@ function PostOuterWrapper({
}>) { }>) {
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const pal = usePalette('default') const pal = usePalette('default')
const styles = useStyles()
if (treeView && depth > 0) { if (treeView && depth > 0) {
return ( return (
<View <View
@ -703,94 +687,84 @@ function ExpandedPostDetails({
) )
} }
const useStyles = () => { const styles = StyleSheet.create({
const {isDesktop} = useWebMediaQueries() outer: {
return StyleSheet.create({ borderTopWidth: 1,
outer: { paddingLeft: 8,
borderTopWidth: 1, },
paddingLeft: 8, outerHighlighted: {
}, paddingTop: 16,
outerHighlighted: { paddingLeft: 8,
paddingTop: 16, paddingRight: 8,
paddingLeft: 8, },
paddingRight: 8, noTopBorder: {
}, borderTopWidth: 0,
noTopBorder: { },
borderTopWidth: 0, layout: {
}, flexDirection: 'row',
layout: { paddingHorizontal: 8,
flexDirection: 'row', },
gap: 10, layoutAvi: {},
paddingLeft: 8, layoutContent: {
}, flex: 1,
layoutAvi: {}, marginLeft: 10,
layoutContent: { },
flex: 1, meta: {
paddingRight: 10, flexDirection: 'row',
}, paddingVertical: 2,
meta: { },
flexDirection: 'row', metaExpandedLine1: {
paddingTop: 2, paddingVertical: 0,
paddingBottom: 2, },
}, alert: {
metaExpandedLine1: { marginBottom: 6,
paddingTop: 0, },
paddingBottom: 0, postTextContainer: {
}, flexDirection: 'row',
metaItem: { alignItems: 'center',
paddingRight: 5, flexWrap: 'wrap',
maxWidth: isDesktop ? 380 : 220, paddingBottom: 4,
}, paddingRight: 10,
alert: { },
marginBottom: 6, postTextLargeContainer: {
}, paddingHorizontal: 0,
postTextContainer: { paddingRight: 0,
flexDirection: 'row', paddingBottom: 10,
alignItems: 'center', },
flexWrap: 'wrap', translateLink: {
paddingBottom: 4, marginBottom: 6,
paddingRight: 10, },
}, contentHider: {
postTextLargeContainer: { marginBottom: 6,
paddingHorizontal: 0, },
paddingRight: 0, contentHiderChild: {
paddingBottom: 10, marginTop: 6,
}, },
translateLink: { expandedInfo: {
marginBottom: 6, flexDirection: 'row',
}, padding: 10,
contentHider: { borderTopWidth: 1,
marginBottom: 6, borderBottomWidth: 1,
}, marginTop: 5,
contentHiderChild: { marginBottom: 15,
marginTop: 6, },
}, expandedInfoItem: {
expandedInfo: { marginRight: 10,
flexDirection: 'row', },
padding: 10, loadMore: {
borderTopWidth: 1, flexDirection: 'row',
borderBottomWidth: 1, alignItems: 'center',
marginTop: 5, justifyContent: 'flex-start',
marginBottom: 15, gap: 4,
}, paddingHorizontal: 20,
expandedInfoItem: { },
marginRight: 10, replyLine: {
}, width: 2,
loadMore: { marginLeft: 'auto',
flexDirection: 'row', marginRight: 'auto',
alignItems: 'center', },
justifyContent: 'flex-start', cursor: {
gap: 4, // @ts-ignore web only
paddingHorizontal: 20, cursor: 'pointer',
}, },
replyLine: { })
width: 2,
marginLeft: 'auto',
marginRight: 'auto',
},
cursor: {
// @ts-ignore web only
cursor: 'pointer',
},
})
}