Files
bsky-social-app/src/view/com/notifications/NotificationFeedItem.tsx
T

1214 lines
37 KiB
TypeScript

import {memo, useCallback, useEffect, useMemo, useState} from 'react'
import {
Animated,
type GestureResponderEvent,
type LayoutChangeEvent,
Pressable,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {
type AppBskyActorDefs,
type AppBskyFeedDefs,
AppBskyFeedPost,
type AppBskyGraphDefs,
AppBskyGraphFollow,
AppBskyGraphStarterpack,
AtUri,
} from '@atproto/api'
import {TID} from '@atproto/common-web'
import {type DidString} from '@atproto/syntax'
import {
type ModerationDecision,
type ModerationOpts,
} from '@bsky.app/sdk/moderation'
import {plural} from '@lingui/core/macro'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {MAX_POST_LINES} from '#/lib/constants'
import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
import {moderateProfile} from '#/lib/moderation/subjects'
import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
import {forceLTR} from '#/lib/strings/bidi'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {niceDate} from '#/lib/strings/time'
import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {type FeedNotification} from '#/state/queries/notifications/feed'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useChatClient, useSession} from '#/state/session'
import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard'
import {Post} from '#/view/com/post/Post'
import {formatCount} from '#/view/com/util/numeric/format'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, platform, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
} from '#/components/icons/Chevron'
import {Contacts_Filled_Corner2_Rounded as ContactsIconFilled} from '#/components/icons/Contacts'
import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2'
import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost'
import {StarterPackMultiPathLarge as StarterPackIcon} from '#/components/icons/StarterPack'
import {VerifiedCheck} from '#/components/icons/VerifiedCheck'
import {InlineLinkText, Link} from '#/components/Link'
import * as MediaPreview from '#/components/MediaPreview'
import {ProfileBadges} from '#/components/ProfileBadges'
import * as ProfileCard from '#/components/ProfileCard'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {
Notification as StarterPackCard,
useStarterPackLink,
} from '#/components/StarterPack/StarterPackCard'
import {SubtleHover} from '#/components/SubtleHover'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {chat} from '#/lexicons'
import * as bsky from '#/types/bsky'
const MAX_AUTHORS = 5
interface Author {
profile: AppBskyActorDefs.ProfileView
href: string
moderation: ModerationDecision
}
let NotificationFeedItem = ({
item,
moderationOpts,
highlightUnread,
hideTopBorder,
}: {
item: FeedNotification
moderationOpts: ModerationOpts
highlightUnread: boolean
hideTopBorder?: boolean
}): React.ReactNode => {
const queryClient = useQueryClient()
const t = useTheme()
const {t: l, i18n} = useLingui()
const ax = useAnalytics()
const [isAuthorsExpanded, setIsAuthorsExpanded] = useState(false)
const [isHoveringAuthorsList, setIsHoveringAuthorsList] = useState(false)
const itemHref = useMemo(() => {
switch (item.type) {
case 'post-like':
case 'repost':
case 'like-via-repost':
case 'repost-via-repost': {
if (item.subjectUri) {
const urip = new AtUri(item.subjectUri)
return `/profile/${urip.host}/post/${urip.rkey}`
}
break
}
case 'follow':
case 'contact-match':
case 'verified':
case 'unverified': {
return makeProfileLink(item.notification.author)
}
case 'reply':
case 'mention':
case 'quote': {
const uripReply = new AtUri(item.notification.uri)
return `/profile/${uripReply.host}/post/${uripReply.rkey}`
}
case 'feedgen-like':
case 'starterpack-joined': {
if (item.subjectUri) {
const urip = new AtUri(item.subjectUri)
return `/profile/${urip.host}/feed/${urip.rkey}`
}
break
}
case 'subscribed-post': {
const posts: string[] = []
for (const post of [item.notification, ...(item.additional ?? [])]) {
posts.push(post.uri)
}
return `/notifications/activity?posts=${encodeURIComponent(posts.slice(0, 25).join(','))}`
}
}
return ''
}, [item])
const onBeforePress = useCallback(() => {
unstableCacheProfileView(queryClient, item.notification.author)
}, [queryClient, item.notification.author])
const authors: Author[] = useMemo(() => {
return [
{
profile: item.notification.author,
href: makeProfileLink(item.notification.author),
moderation: moderateProfile(item.notification.author, moderationOpts),
},
...(item.additional?.map(({author}) => ({
profile: author,
href: makeProfileLink(author),
moderation: moderateProfile(author, moderationOpts),
})) || []),
].filter(
(author, index, arr) =>
arr.findIndex(au => au.profile.did === author.profile.did) === index,
)
}, [item, moderationOpts])
const onToggleAuthorsExpanded = (e?: GestureResponderEvent) => {
if (e) {
e.preventDefault()
e.stopPropagation()
}
if (!isAuthorsExpanded) {
ax.metric('notifications:bundleExpand', {
notificationType: item.type,
authorCount: authors.length,
})
}
setIsAuthorsExpanded(currentlyExpanded => !currentlyExpanded)
}
const niceTimestamp = niceDate(i18n, item.notification.indexedAt)
const firstAuthor = authors[0]
const firstAuthorName = sanitizeDisplayName(
firstAuthor.profile.displayName || firstAuthor.profile.handle,
)
// Calculate if this is a follow-back notification
const isFollowBack = useMemo(() => {
if (item.type !== 'follow') return false
if (
item.notification.author.viewer?.following &&
bsky.dangerousIsType<AppBskyGraphFollow.Record>(
item.notification.record,
AppBskyGraphFollow.isRecord,
)
) {
let followingTimestamp
try {
const rkey = new AtUri(item.notification.author.viewer.following).rkey
followingTimestamp = TID.fromStr(rkey).timestamp()
} catch (e) {
return false
}
if (followingTimestamp) {
const followedTimestamp =
new Date(item.notification.record.createdAt).getTime() * 1000
return followedTimestamp > followingTimestamp
}
}
return false
}, [item])
if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') {
// don't render anything if the target post was deleted or unfindable
return <View />
}
if (
item.type === 'reply' ||
item.type === 'mention' ||
item.type === 'quote'
) {
if (!item.subject) {
return null
}
const isHighlighted = highlightUnread && !item.notification.isRead
return (
<View testID={`feedItem-by-${item.notification.author.handle}`}>
<Post
post={item.subject}
style={
isHighlighted && {
backgroundColor: t.palette.primary_25,
borderColor: t.palette.primary_100,
}
}
hideTopBorder={hideTopBorder}
/>
</View>
)
}
const firstAuthorLink = (
<ProfileHoverCard did={firstAuthor.profile.did} inline>
<InlineLinkText
key={firstAuthor.href}
style={[
t.atoms.text,
a.font_semi_bold,
a.text_md,
web({direction: 'ltr', unicodeBidi: 'isolate'}),
]}
to={firstAuthor.href}
disableMismatchWarning
emoji
label={l`Go to ${firstAuthorName}'s profile`}>
{forceLTR(firstAuthorName)}
<ProfileBadges
profile={firstAuthor.profile}
size="sm"
style={[a.px_2xs, {transform: [{translateY: 1}]}]}
/>
</InlineLinkText>
</ProfileHoverCard>
)
const additionalAuthorsCount = authors.length - 1
const hasMultipleAuthors = additionalAuthorsCount > 0
const starterPack = item.notification.starterPack
const allFollowedViaSameStarterPack =
item.type === 'follow' &&
starterPack !== undefined &&
(item.additional ?? []).every(
notification => notification.starterPack?.uri === starterPack.uri,
)
const starterPackName =
allFollowedViaSameStarterPack && starterPack
? getStarterPackName(starterPack)
: undefined
const formattedAuthorsCount = hasMultipleAuthors
? formatCount(i18n, additionalAuthorsCount)
: ''
let a11yLabel = ''
let notificationContent: React.ReactElement
let icon = (
<HeartIconFilled
size="xl"
style={[
{color: t.palette.pink},
// {position: 'relative', top: -4}
]}
/>
)
if (item.type === 'post-like') {
a11yLabel = hasMultipleAuthors
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} liked your post`
: l`${firstAuthorName} liked your post`
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
other={`${formattedAuthorsCount} others`}
/>
</Text>{' '}
liked your post
</Trans>
) : (
<Trans>{firstAuthorLink} liked your post</Trans>
)
} else if (item.type === 'repost') {
a11yLabel = hasMultipleAuthors
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} reposted your post`
: l`${firstAuthorName} reposted your post`
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
other={`${formattedAuthorsCount} others`}
/>
</Text>{' '}
reposted your post
</Trans>
) : (
<Trans>{firstAuthorLink} reposted your post</Trans>
)
icon = <RepostIcon size="xl" style={{color: t.palette.positive_500}} />
} else if (item.type === 'follow') {
if (isFollowBack && !hasMultipleAuthors) {
/*
* Follow-backs are ungrouped, grouped follow-backs not supported atm,
* see `src/state/queries/notifications/util.ts`
*/
a11yLabel = starterPackName
? l`${firstAuthorName} followed you back via starter pack ${starterPackName}`
: l`${firstAuthorName} followed you back`
notificationContent = <Trans>{firstAuthorLink} followed you back</Trans>
} else {
a11yLabel = starterPackName
? hasMultipleAuthors
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} followed you via starter pack ${starterPackName}`
: l`${firstAuthorName} followed you via starter pack ${starterPackName}`
: hasMultipleAuthors
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} followed you`
: l`${firstAuthorName} followed you`
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
other={`${formattedAuthorsCount} others`}
/>
</Text>{' '}
followed you
</Trans>
) : (
<Trans>{firstAuthorLink} followed you</Trans>
)
}
icon = <PersonPlusIcon size="xl" style={{color: t.palette.primary_500}} />
} else if (item.type === 'contact-match') {
a11yLabel = l`Your contact ${firstAuthorName} is on Bluesky`
notificationContent = (
<Trans>Your contact {firstAuthorLink} is on Bluesky</Trans>
)
icon = (
<ContactsIconFilled size="xl" style={{color: t.palette.primary_500}} />
)
} else if (item.type === 'feedgen-like') {
a11yLabel = hasMultipleAuthors
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} liked your custom feed`
: l`${firstAuthorName} liked your custom feed`
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
other={`${formattedAuthorsCount} others`}
/>
</Text>{' '}
liked your custom feed
</Trans>
) : (
<Trans>{firstAuthorLink} liked your custom feed</Trans>
)
} else if (item.type === 'starterpack-joined') {
a11yLabel = hasMultipleAuthors
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} signed up with your starter pack`
: l`${firstAuthorName} signed up with your starter pack`
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
other={`${formattedAuthorsCount} others`}
/>
</Text>{' '}
signed up with your starter pack
</Trans>
) : (
<Trans>{firstAuthorLink} signed up with your starter pack</Trans>
)
icon = (
<View style={{height: 30, width: 30}}>
<StarterPackIcon width={30} gradient="sky" />
</View>
)
} else if (item.type === 'verified') {
a11yLabel = hasMultipleAuthors
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} verified you`
: l`${firstAuthorName} verified you`
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
other={`${formattedAuthorsCount} others`}
/>
</Text>{' '}
verified you
</Trans>
) : (
<Trans>{firstAuthorLink} verified you</Trans>
)
icon = <VerifiedCheck size="xl" />
} else if (item.type === 'unverified') {
a11yLabel = hasMultipleAuthors
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} removed their verifications from your account`
: l`${firstAuthorName} removed their verification from your account`
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
other={`${formattedAuthorsCount} others`}
/>
</Text>{' '}
removed their verifications from your account
</Trans>
) : (
<Trans>
{firstAuthorLink} removed their verification from your account
</Trans>
)
icon = <VerifiedCheck size="xl" fill={t.palette.contrast_500} />
} else if (item.type === 'like-via-repost') {
a11yLabel = hasMultipleAuthors
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} liked your repost`
: l`${firstAuthorName} liked your repost`
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
other={`${formattedAuthorsCount} others`}
/>
</Text>{' '}
liked your repost
</Trans>
) : (
<Trans>{firstAuthorLink} liked your repost</Trans>
)
} else if (item.type === 'repost-via-repost') {
a11yLabel = hasMultipleAuthors
? l`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} reposted your repost`
: l`${firstAuthorName} reposted your repost`
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
other={`${formattedAuthorsCount} others`}
/>
</Text>{' '}
reposted your repost
</Trans>
) : (
<Trans>{firstAuthorLink} reposted your repost</Trans>
)
icon = <RepostIcon size="xl" style={{color: t.palette.positive_500}} />
} else if (item.type === 'subscribed-post') {
const postsCount = 1 + (item.additional?.length || 0)
a11yLabel = hasMultipleAuthors
? l`New posts from ${firstAuthorName} and ${plural(
additionalAuthorsCount,
{
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
},
)}`
: l`New ${plural(postsCount, {
one: 'post',
other: 'posts',
})} from ${firstAuthorName}`
notificationContent = hasMultipleAuthors ? (
<Trans>
New posts from {firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
other={`${formattedAuthorsCount} others`}
/>
</Text>{' '}
</Trans>
) : (
<Trans>
New <Plural value={postsCount} one="post" other="posts" /> from{' '}
{firstAuthorLink}
</Trans>
)
icon = <BellRingingIcon size="xl" style={{color: t.palette.primary_500}} />
} else {
return null
}
a11yLabel += ` · ${niceTimestamp}`
return (
<Link
label={a11yLabel}
testID={`feedItem-by-${item.notification.author.handle}`}
style={[
a.flex_row,
a.align_start,
{padding: 10},
a.pr_lg,
t.atoms.border_contrast_low,
item.notification.isRead
? undefined
: {
backgroundColor: t.palette.primary_25,
borderColor: t.palette.primary_100,
},
!hideTopBorder && a.border_t,
// Clip horizontal overflow (in case of long handles) but let the timestamp overflow the bottom of the cell.
platform({
web: {overflowX: 'clip', overflowY: 'visible'},
native: {overflow: 'hidden'},
}),
]}
to={itemHref}
accessible={!isAuthorsExpanded}
accessibilityActions={
hasMultipleAuthors
? [
{
name: 'toggleAuthorsExpanded',
label: isAuthorsExpanded
? l`Collapse list of users`
: l`Expand list of users`,
},
]
: [
{
name: 'viewProfile',
label: l`View ${
authors[0].profile.displayName || authors[0].profile.handle
}'s profile`,
},
]
}
onAccessibilityAction={e => {
if (e.nativeEvent.actionName === 'activate') {
onBeforePress()
}
if (e.nativeEvent.actionName === 'toggleAuthorsExpanded') {
onToggleAuthorsExpanded()
}
}}>
{({hovered}) => (
<>
<SubtleHover hover={hovered && !isHoveringAuthorsList} />
<View style={[styles.layoutIcon, a.pr_sm]}>
{/* TODO: Prevent conditional rendering and move toward composable
notifications for clearer accessibility labeling */}
{icon}
</View>
<View style={[a.flex_1]}>
<ExpandListPressable
hasMultipleAuthors={hasMultipleAuthors}
onToggleAuthorsExpanded={onToggleAuthorsExpanded}
onHoverIn={() => setIsHoveringAuthorsList(true)}
onHoverOut={() => setIsHoveringAuthorsList(false)}>
<CondensedAuthorsList
visible={!isAuthorsExpanded}
authors={authors}
onToggleAuthorsExpanded={onToggleAuthorsExpanded}
showDmButton={item.type === 'starterpack-joined'}
/>
<ExpandedAuthorsList
visible={isAuthorsExpanded}
authors={authors}
moderationOpts={moderationOpts}
/>
<Text
style={[
a.flex_row,
a.flex_wrap,
{paddingTop: 6},
a.self_start,
a.text_md,
]}
accessibilityHint=""
accessibilityLabel={a11yLabel}>
{notificationContent}
<TimeElapsed timestamp={item.notification.indexedAt}>
{({timeElapsed}) => (
<>
{/* make sure there's whitespace around the middot -sfn */}
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
{' '}
&middot;{' '}
</Text>
<Text
style={[a.text_md, t.atoms.text_contrast_medium]}
title={niceTimestamp}>
{timeElapsed}
</Text>
</>
)}
</TimeElapsed>
</Text>
</ExpandListPressable>
{allFollowedViaSameStarterPack && starterPack ? (
<FollowedViaStarterPack starterPack={starterPack} />
) : null}
{(item.type === 'follow' && !hasMultipleAuthors && !isFollowBack) ||
(item.type === 'contact-match' &&
!item.notification.author.viewer?.following) ? (
<FollowBackButton profile={item.notification.author} />
) : null}
{item.type === 'post-like' ||
item.type === 'repost' ||
item.type === 'like-via-repost' ||
item.type === 'repost-via-repost' ||
item.type === 'subscribed-post' ? (
<View style={[a.pt_2xs]}>
<AdditionalPostText post={item.subject} />
</View>
) : null}
{item.type === 'feedgen-like' && item.subjectUri ? (
<FeedSourceCard
feedUri={item.subjectUri}
link={false}
style={[
t.atoms.bg,
t.atoms.border_contrast_low,
a.border,
a.p_md,
styles.feedcard,
]}
showLikes
/>
) : null}
{item.type === 'starterpack-joined' ? (
<View>
<View
style={[
a.border,
a.p_sm,
a.rounded_sm,
a.mt_sm,
t.atoms.border_contrast_low,
]}>
<StarterPackCard starterPack={item.subject} />
</View>
</View>
) : null}
</View>
</>
)}
</Link>
)
}
NotificationFeedItem = memo(NotificationFeedItem)
export {NotificationFeedItem}
function FollowedViaStarterPack({
starterPack,
}: {
starterPack: AppBskyGraphDefs.StarterPackViewBasic
}) {
const t = useTheme()
const link = useStarterPackLink({view: starterPack})
const starterPackName = getStarterPackName(starterPack)
if (!starterPackName) {
return null
}
return (
<Text style={[native(a.pt_xs), t.atoms.text_contrast_medium]}>
<Trans comment="When the source of a follow is a starter pack, i.e., 'via starter pack {starterPackName}'.">
via starter pack{' '}
<StarterPackIcon
size="sm"
gradient="sky"
style={[native(a.mr_2xs), {transform: [{translateY: 4}]}]}
/>
<InlineLinkText
to={link.to}
label={link.label}
onPress={link.precache}
onMouseEnter={link.precache}
style={[a.font_semi_bold, t.atoms.text]}>
{starterPackName}
</InlineLinkText>
</Trans>
</Text>
)
}
function getStarterPackName(
starterPack: AppBskyGraphDefs.StarterPackViewBasic,
) {
return bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
starterPack.record,
AppBskyGraphStarterpack.isRecord,
)
? starterPack.record.name
: undefined
}
function ExpandListPressable({
hasMultipleAuthors,
children,
onToggleAuthorsExpanded,
onHoverIn,
onHoverOut,
}: {
hasMultipleAuthors: boolean
children: React.ReactNode
onToggleAuthorsExpanded: (e: GestureResponderEvent) => void
onHoverIn?: () => void
onHoverOut?: () => void
}) {
if (hasMultipleAuthors) {
return (
<Pressable
onPress={onToggleAuthorsExpanded}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
style={[styles.expandedAuthorsTrigger]}
accessible={false}>
{children}
</Pressable>
)
} else {
return <>{children}</>
}
}
function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) {
const {t: l} = useLingui()
const {currentAccount, hasSession} = useSession()
const profileShadow = useProfileShadow(profile)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profileShadow,
'ProfileCard',
)
// Don't show button if not logged in or for own profile
if (!hasSession || profile.did === currentAccount?.did) {
return null
}
const onPressFollow = async (e: GestureResponderEvent) => {
e.preventDefault()
e.stopPropagation()
try {
await queueFollow()
Toast.show(
l`Following ${sanitizeDisplayName(
profile.displayName || profile.handle,
)}`,
)
} catch (error) {
const err = error as Error
if (err?.name !== 'AbortError') {
Toast.show(l`An issue occurred, please try again.`, {
type: 'error',
})
}
}
}
const onPressUnfollow = async (e: GestureResponderEvent) => {
e.preventDefault()
e.stopPropagation()
try {
await queueUnfollow()
Toast.show(
l`No longer following ${sanitizeDisplayName(
profile.displayName || profile.handle,
)}`,
)
} catch (error) {
const err = error as Error
if (err?.name !== 'AbortError') {
Toast.show(l`An issue occurred, please try again.`, {
type: 'error',
})
}
}
}
// Don't show button if viewer data is missing or user is blocked
if (!profileShadow.viewer) {
return null
}
if (
profileShadow.viewer.blockedBy ||
profileShadow.viewer.blocking ||
profileShadow.viewer.blockingByList
) {
return null
}
const isFollowing = profileShadow.viewer.following
const isFollowedBy = profileShadow.viewer.followedBy
const followingLabel = l({
message: 'Following',
comment: 'User is following this account, click to unfollow',
})
return (
<View style={[a.pt_sm]}>
{isFollowing ? (
<Button
label={followingLabel}
color="secondary"
size="small"
style={[a.self_start]}
onPress={(e: GestureResponderEvent) => void onPressUnfollow(e)}>
<ButtonIcon icon={CheckIcon} />
<ButtonText>
<Trans>Following</Trans>
</ButtonText>
</Button>
) : (
<Button
label={isFollowedBy ? l`Follow back` : l`Follow`}
color="primary"
size="small"
style={[a.self_start]}
onPress={(e: GestureResponderEvent) => void onPressFollow(e)}>
<ButtonIcon icon={PlusIcon} />
<ButtonText>
{isFollowedBy ? <Trans>Follow back</Trans> : <Trans>Follow</Trans>}
</ButtonText>
</Button>
)}
</View>
)
}
function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) {
const {t: l} = useLingui()
const client = useChatClient()
const {currentAccount} = useSession()
const navigation = useNavigation<NavigationProp>()
const [isLoading, setIsLoading] = useState(false)
const onPress = async () => {
try {
setIsLoading(true)
const data = await client.call(chat.bsky.convo.getConvoForMembers, {
// both dids are already resolved - one from the profile view, one from
// the active session
members: [profile.did, currentAccount!.did] as DidString[],
})
navigation.navigate('MessagesConversation', {
conversation: data.convo.id,
})
} catch (e) {
logger.error('Failed to get conversation', {safeMessage: e})
} finally {
setIsLoading(false)
}
}
if (
profile.associated?.chat?.allowIncoming === 'none' ||
(profile.associated?.chat?.allowIncoming === 'following' &&
!profile.viewer?.followedBy)
) {
return null
}
return (
<Button
label={l`Say hello!`}
variant="ghost"
color="primary"
size="small"
style={[a.self_center, {marginLeft: 'auto'}]}
disabled={isLoading}
onPress={() => void onPress()}>
<ButtonText>
<Trans>Say hello!</Trans>
</ButtonText>
</Button>
)
}
function CondensedAuthorsList({
visible,
authors,
onToggleAuthorsExpanded,
showDmButton = true,
}: {
visible: boolean
authors: Author[]
onToggleAuthorsExpanded: (e: GestureResponderEvent) => void
showDmButton?: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
if (!visible) {
return (
<View style={[a.flex_row, a.align_center]}>
<TouchableOpacity
style={styles.expandedAuthorsCloseBtn}
onPress={onToggleAuthorsExpanded}
accessibilityRole="button"
accessibilityLabel={l`Hide user list`}
accessibilityHint={l`Collapses list of users for a given notification`}>
<ChevronUpIcon
size="md"
style={[a.ml_xs, a.mr_md, t.atoms.text_contrast_high]}
/>
<Text style={[a.text_md, t.atoms.text_contrast_high]}>
<Trans context="action">Hide</Trans>
</Text>
</TouchableOpacity>
</View>
)
}
if (authors.length === 1) {
return (
<View style={[a.flex_row, a.align_center]}>
<PreviewableUserAvatar
size={35}
profile={authors[0].profile}
moderation={authors[0].moderation.ui('avatar')}
type={authors[0].profile.associated?.labeler ? 'labeler' : 'user'}
/>
{showDmButton ? <SayHelloBtn profile={authors[0].profile} /> : null}
</View>
)
}
return (
<TouchableOpacity
accessibilityRole="none"
onPress={onToggleAuthorsExpanded}>
<View style={[a.flex_row, a.align_center]}>
{authors.slice(0, MAX_AUTHORS).map(author => (
<View key={author.href} style={{marginRight: 5}}>
<PreviewableUserAvatar
size={35}
profile={author.profile}
moderation={author.moderation.ui('avatar')}
type={author.profile.associated?.labeler ? 'labeler' : 'user'}
/>
</View>
))}
{authors.length > MAX_AUTHORS ? (
<Text
style={[
a.font_semi_bold,
{paddingLeft: 6},
t.atoms.text_contrast_medium,
]}>
+{authors.length - MAX_AUTHORS}
</Text>
) : undefined}
<ChevronDownIcon
size="md"
style={[a.mx_xs, t.atoms.text_contrast_medium]}
/>
</View>
</TouchableOpacity>
)
}
function ExpandedAuthorsList({
visible,
authors,
moderationOpts,
}: {
visible: boolean
authors: Author[]
moderationOpts: ModerationOpts
}) {
const heightInterp = useAnimatedValue(visible ? 1 : 0)
const opacityInterp = useAnimatedValue(visible ? 1 : 0)
const [measuredHeight, setMeasuredHeight] = useState(0)
useEffect(() => {
Animated.timing(IS_WEB ? heightInterp : opacityInterp, {
toValue: visible ? 1 : 0,
duration: 200,
useNativeDriver: !IS_WEB,
}).start()
}, [heightInterp, opacityInterp, visible])
const onInnerLayout = (e: LayoutChangeEvent) => {
if (measuredHeight === 0) {
setMeasuredHeight(e.nativeEvent.layout.height)
}
}
if (!IS_WEB) {
return (
<Animated.View style={{opacity: opacityInterp}}>
{visible && (
<View style={[a.pt_sm, a.pb_md]}>
{authors.map((author, i) => (
<ExpandedAuthorProfileCard
key={author.profile.did}
author={author}
moderationOpts={moderationOpts}
isLast={i === authors.length - 1}
/>
))}
</View>
)}
</Animated.View>
)
}
const heightStyle = {
height: Animated.multiply(heightInterp, measuredHeight),
opacity: Animated.divide(heightInterp, 1),
}
return (
<Animated.View style={[a.overflow_hidden, heightStyle]}>
<View onLayout={onInnerLayout} style={[a.pt_sm, a.pb_md]}>
{authors.map((author, i) => (
<ExpandedAuthorProfileCard
key={author.profile.did}
author={author}
moderationOpts={moderationOpts}
isLast={i === authors.length - 1}
/>
))}
</View>
</Animated.View>
)
}
function ExpandedAuthorProfileCard({
author,
moderationOpts,
isLast,
}: {
author: Author
moderationOpts: ModerationOpts
isLast: boolean
}) {
const profile = useProfileShadow(author.profile)
const isFollowing = !!profile.viewer?.following
return (
<ProfileCard.Link
profile={author.profile}
style={isLast ? undefined : a.pb_md}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={author.profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={author.profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={author.profile}
moderationOpts={moderationOpts}
logContext="NotificationExpandedProfileCard"
size="tiny"
variant={isFollowing ? 'ghost' : 'solid'}
color={isFollowing ? 'secondary' : 'primary_subtle'}
withIcon={isFollowing}
/>
</ProfileCard.Header>
</ProfileCard.Outer>
</ProfileCard.Link>
)
}
function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
const t = useTheme()
if (
post &&
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post?.record,
AppBskyFeedPost.isRecord,
)
) {
const text = post.record.text
return (
<>
{text?.length > 0 && (
<Text
emoji
style={[a.text_sm, t.atoms.text_contrast_medium]}
numberOfLines={MAX_POST_LINES}>
{text}
</Text>
)}
<MediaPreview.Embed
embed={post.embed}
style={styles.additionalPostImages}
peekable
/>
</>
)
}
}
const styles = StyleSheet.create({
layoutIcon: {
width: 60,
alignItems: 'flex-end',
paddingTop: 2,
},
icon: {
marginRight: 10,
marginTop: 4,
},
additionalPostImages: {
marginTop: 5,
marginLeft: 2,
opacity: 0.8,
},
feedcard: {
borderRadius: 8,
marginTop: 6,
},
addedContainer: {
paddingTop: 4,
paddingLeft: 36,
},
expandedAuthorsTrigger: {
zIndex: 1,
},
expandedAuthorsCloseBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: 10,
paddingBottom: 6,
},
})