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 namezio/stable
parent
a97d469981
commit
fe57335b86
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -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)}>
|
|
||||||
· {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',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue