Files
bsky-social-app/src/view/com/notifications/NotificationFeedItem.tsx
T
Claude 34d347400a Use snug leading for the notification author name
The author name link was the only `leading_tight` fragment in the notification
text. It is also the paragraph's first fragment, and TextKit takes paragraph
line metrics from the paragraph's first character, so it set the line height for
the whole block: every line laid out at 17pt instead of the 20pt the
surrounding text asks for (measured 51px line spacing at @3x).

That also squeezed the inline badges. At 17pt the ascent budget is 13.4pt, less
than the 14pt badge box, so TextKit had to shift the attachment up to make it
fit and the box bottom no longer landed on the baseline. With snug leading the
ascent budget is 16.4pt and the box sits where `RCTTextLayoutManager` puts it,
which is what the cap-height sizing assumes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pgeau3P52qsMTcnDeJm8Zn
2026-07-25 09:48:22 +00:00

1186 lines
36 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,
AppBskyGraphFollow,
moderateProfile,
type ModerationDecision,
type ModerationOpts,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {TID} from '@atproto/common-web'
import {msg, plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS, MAX_POST_LINES} from '#/lib/constants'
import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
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 {s} from '#/lib/styles'
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 {useAgent, 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, 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 {StarterPack} 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} 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 * 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 {_, i18n} = useLingui()
const ax = useAnalytics()
const [isAuthorsExpanded, setIsAuthorsExpanded] = useState<boolean>(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,
/*
* Must match the leading of the surrounding text. On iOS the whole
* paragraph takes its line metrics from the first character, so a
* tighter leading here silently compressed every line of the
* notification.
*/
a.leading_snug,
web({direction: 'ltr', unicodeBidi: 'isolate'}),
]}
to={firstAuthor.href}
disableMismatchWarning
emoji
label={_(msg`Go to ${firstAuthorName}'s profile`)}>
{forceLTR(firstAuthorName)}
<ProfileBadges
profile={firstAuthor.profile}
size="md"
inlineFontSize={a.text_md.fontSize}
style={[
a.relative,
{
/*
* Empirical nudges to line the badges up with the text on
* Android and web. iOS is handled by `inlineFontSize` above.
*/
paddingTop: platform({android: 2}),
top: platform({web: 2}),
paddingLeft: 3,
paddingRight: 2,
},
]}
/>
</InlineLinkText>
</ProfileHoverCard>
)
const additionalAuthorsCount = authors.length - 1
const hasMultipleAuthors = additionalAuthorsCount > 0
const formattedAuthorsCount = hasMultipleAuthors
? formatCount(i18n, additionalAuthorsCount)
: ''
let a11yLabel = ''
let notificationContent: React.ReactElement<any>
let icon = (
<HeartIconFilled
size="xl"
style={[
{color: t.palette.pink},
// {position: 'relative', top: -4}
]}
/>
)
if (item.type === 'post-like') {
a11yLabel = hasMultipleAuthors
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} liked your post`,
)
: _(msg`${firstAuthorName} liked your post`)
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<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
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} reposted your post`,
)
: _(msg`${firstAuthorName} reposted your post`)
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<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 = _(msg`${firstAuthorName} followed you back`)
notificationContent = <Trans>{firstAuthorLink} followed you back</Trans>
} else {
a11yLabel = hasMultipleAuthors
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} followed you`,
)
: _(msg`${firstAuthorName} followed you`)
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<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 = _(msg`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
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} liked your custom feed`,
)
: _(msg`${firstAuthorName} liked your custom feed`)
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<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
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} signed up with your starter pack`,
)
: _(msg`${firstAuthorName} signed up with your starter pack`)
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<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}}>
<StarterPack width={30} gradient="sky" />
</View>
)
} else if (item.type === 'verified') {
a11yLabel = hasMultipleAuthors
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} verified you`,
)
: _(msg`${firstAuthorName} verified you`)
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<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
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} removed their verifications from your account`,
)
: _(msg`${firstAuthorName} removed their verification from your account`)
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<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
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} liked your repost`,
)
: _(msg`${firstAuthorName} liked your repost`)
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<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
? _(
msg`${firstAuthorName} and ${plural(additionalAuthorsCount, {
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
})} reposted your repost`,
)
: _(msg`${firstAuthorName} reposted your repost`)
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<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
? _(
msg`New posts from ${firstAuthorName} and ${plural(
additionalAuthorsCount,
{
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
},
)}`,
)
: _(
msg`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, a.leading_snug]}>
<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
? _(msg`Collapse list of users`)
: _(msg`Expand list of users`),
},
]
: [
{
name: 'viewProfile',
label: _(
msg`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,
a.leading_snug,
]}
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>
{(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 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 {_} = 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(
_(
msg`Following ${sanitizeDisplayName(
profile.displayName || profile.handle,
)}`,
),
)
} catch (err: any) {
if (err?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), {
type: 'error',
})
}
}
}
const onPressUnfollow = async (e: GestureResponderEvent) => {
e.preventDefault()
e.stopPropagation()
try {
await queueUnfollow()
Toast.show(
_(
msg`No longer following ${sanitizeDisplayName(
profile.displayName || profile.handle,
)}`,
),
)
} catch (err: any) {
if (err?.name !== 'AbortError') {
Toast.show(_(msg`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 = _(
msg({
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={onPressUnfollow}>
<ButtonIcon icon={CheckIcon} />
<ButtonText>
<Trans>Following</Trans>
</ButtonText>
</Button>
) : (
<Button
label={isFollowedBy ? _(msg`Follow back`) : _(msg`Follow`)}
color="primary"
size="small"
style={[a.self_start]}
onPress={onPressFollow}>
<ButtonIcon icon={PlusIcon} />
<ButtonText>
{isFollowedBy ? <Trans>Follow back</Trans> : <Trans>Follow</Trans>}
</ButtonText>
</Button>
)}
</View>
)
}
function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) {
const {_} = useLingui()
const agent = useAgent()
const navigation = useNavigation<NavigationProp>()
const [isLoading, setIsLoading] = useState(false)
if (
profile.associated?.chat?.allowIncoming === 'none' ||
(profile.associated?.chat?.allowIncoming === 'following' &&
!profile.viewer?.followedBy)
) {
return null
}
return (
<Button
label={_(msg`Say hello!`)}
variant="ghost"
color="primary"
size="small"
style={[a.self_center, {marginLeft: 'auto'}]}
disabled={isLoading}
onPress={async () => {
try {
setIsLoading(true)
const res = await agent.api.chat.bsky.convo.getConvoForMembers(
{
members: [profile.did, agent.session!.did],
},
{headers: DM_SERVICE_HEADERS},
)
navigation.navigate('MessagesConversation', {
conversation: res.data.convo.id,
})
} catch (e) {
logger.error('Failed to get conversation', {safeMessage: e})
} finally {
setIsLoading(false)
}
}}>
<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 {_} = useLingui()
if (!visible) {
return (
<View style={[a.flex_row, a.align_center]}>
<TouchableOpacity
style={styles.expandedAuthorsCloseBtn}
onPress={onToggleAuthorsExpanded}
accessibilityRole="button"
accessibilityLabel={_(msg`Hide user list`)}
accessibilityHint={_(
msg`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={s.mr5}>
<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, a.leading_snug, 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,
},
})