1214 lines
37 KiB
TypeScript
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]}>
|
|
{' '}
|
|
·{' '}
|
|
</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,
|
|
},
|
|
})
|