34d347400a
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
1186 lines
36 KiB
TypeScript
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]}>
|
|
{' '}
|
|
·{' '}
|
|
</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,
|
|
},
|
|
})
|