Files
bsky-social-app/src/view/com/notifications/NotificationFeedItem.tsx
T
dan f8cdd6b9ae [Notifications] Add a Mentions tab (#7044)
* Split out NotificationsTab

* Remove unused route parameter

* Refine the split between components

* Hoist some logic out of NotificationFeed

* Remove unused option

* Add all|conversations to query, hardcode "all"

* Add a Conversations tab

* Rename to Mentions

* Bump packages

* Rename fields

* Fix oopsie

* Simplify header

* Track active tab

* Fix types

* Separate logic for tabs

* Better border for first unread

* Highlight unread for all only

* Fix spinner races

* Fix fetchPage races

* Fix bottom bar border being obscured by glimmer

* Remember last tab within the session

* One tab at a time

* Fix TS

* Handle all RQKEY usages

* Nit
2024-12-12 17:37:07 +00:00

819 lines
24 KiB
TypeScript

import React, {
memo,
type ReactElement,
useEffect,
useMemo,
useState,
} from 'react'
import {
Animated,
Pressable,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphFollow,
moderateProfile,
ModerationDecision,
ModerationOpts,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {TID} from '@atproto/common-web'
import {msg, Plural, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
import {usePalette} from '#/lib/hooks/usePalette'
import {makeProfileLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {forceLTR} from '#/lib/strings/bidi'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {niceDate} from '#/lib/strings/time'
import {colors, s} from '#/lib/styles'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
import {FeedNotification} from '#/state/queries/notifications/feed'
import {precacheProfile} from '#/state/queries/profile'
import {useAgent} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
} from '#/components/icons/Chevron'
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 {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
import {StarterPack} from '#/components/icons/StarterPack'
import {Link as NewLink} from '#/components/Link'
import * as MediaPreview from '#/components/MediaPreview'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {SubtleWebHover} from '#/components/SubtleWebHover'
import {FeedSourceCard} from '../feeds/FeedSourceCard'
import {Post} from '../post/Post'
import {Link, TextLink} from '../util/Link'
import {formatCount} from '../util/numeric/format'
import {Text} from '../util/text/Text'
import {TimeElapsed} from '../util/TimeElapsed'
import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar'
const MAX_AUTHORS = 5
const EXPANDED_AUTHOR_EL_HEIGHT = 35
interface Author {
profile: AppBskyActorDefs.ProfileViewBasic
href: string
moderation: ModerationDecision
}
let NotificationFeedItem = ({
item,
moderationOpts,
highlightUnread,
hideTopBorder,
}: {
item: FeedNotification
moderationOpts: ModerationOpts
highlightUnread: boolean
hideTopBorder?: boolean
}): React.ReactNode => {
const queryClient = useQueryClient()
const pal = usePalette('default')
const {_, i18n} = useLingui()
const t = useTheme()
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
const itemHref = useMemo(() => {
if (item.type === 'post-like' || item.type === 'repost') {
if (item.subjectUri) {
const urip = new AtUri(item.subjectUri)
return `/profile/${urip.host}/post/${urip.rkey}`
}
} else if (item.type === 'follow') {
return makeProfileLink(item.notification.author)
} else if (item.type === 'reply') {
const urip = new AtUri(item.notification.uri)
return `/profile/${urip.host}/post/${urip.rkey}`
} else if (
item.type === 'feedgen-like' ||
item.type === 'starterpack-joined'
) {
if (item.subjectUri) {
const urip = new AtUri(item.subjectUri)
return `/profile/${urip.host}/feed/${urip.rkey}`
}
}
return ''
}, [item])
const onToggleAuthorsExpanded = () => {
setAuthorsExpanded(currentlyExpanded => !currentlyExpanded)
}
const onBeforePress = React.useCallback(() => {
precacheProfile(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),
})) || []),
]
}, [item, moderationOpts])
const [hover, setHover] = React.useState(false)
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 (
<Link
testID={`feedItem-by-${item.notification.author.handle}`}
href={itemHref}
noFeedback
accessible={false}>
<Post
post={item.subject}
style={
isHighlighted && {
backgroundColor: pal.colors.unreadNotifBg,
borderColor: pal.colors.unreadNotifBorder,
}
}
hideTopBorder={hideTopBorder}
/>
</Link>
)
}
const niceTimestamp = niceDate(i18n, item.notification.indexedAt)
const firstAuthor = authors[0]
const firstAuthorName = sanitizeDisplayName(
firstAuthor.profile.displayName || firstAuthor.profile.handle,
)
const firstAuthorLink = (
<TextLink
key={firstAuthor.href}
style={[pal.text, s.bold]}
href={firstAuthor.href}
text={
<Text emoji style={[pal.text, s.bold]}>
{forceLTR(firstAuthorName)}
</Text>
}
disableMismatchWarning
/>
)
const additionalAuthorsCount = authors.length - 1
const hasMultipleAuthors = additionalAuthorsCount > 0
const formattedAuthorsCount = hasMultipleAuthors
? formatCount(i18n, additionalAuthorsCount)
: ''
let a11yLabel = ''
let notificationContent: ReactElement
let icon = (
<HeartIconFilled
size="xl"
style={[
s.likeColor,
// {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={[pal.text, s.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
? _(
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={[pal.text, s.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_600}} />
} else if (item.type === 'follow') {
let isFollowBack = false
if (
item.notification.author.viewer?.following &&
AppBskyGraphFollow.isRecord(item.notification.record)
) {
let followingTimestamp
try {
const rkey = new AtUri(item.notification.author.viewer.following).rkey
followingTimestamp = TID.fromStr(rkey).timestamp()
} catch (e) {
// For some reason the following URI was invalid. Default to it not being a follow back.
console.error('Invalid following URI')
}
if (followingTimestamp) {
const followedTimestamp =
new Date(item.notification.record.createdAt).getTime() * 1000
isFollowBack = followedTimestamp > followingTimestamp
}
}
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={[pal.text, s.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 === '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={[pal.text, s.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
? _(
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={[pal.text, s.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}}>
<StarterPack width={30} gradient="sky" />
</View>
)
} else {
return null
}
a11yLabel += ` · ${niceTimestamp}`
return (
<Link
testID={`feedItem-by-${item.notification.author.handle}`}
style={[
styles.outer,
pal.border,
item.notification.isRead
? undefined
: {
backgroundColor: pal.colors.unreadNotifBg,
borderColor: pal.colors.unreadNotifBorder,
},
{borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
a.overflow_hidden,
]}
href={itemHref}
noFeedback
accessibilityHint=""
accessibilityLabel={a11yLabel}
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()
}
}}
onPointerEnter={() => {
setHover(true)
}}
onPointerLeave={() => {
setHover(false)
}}>
<SubtleWebHover hover={hover} />
<View style={[styles.layoutIcon, a.pr_sm]}>
{/* TODO: Prevent conditional rendering and move toward composable
notifications for clearer accessibility labeling */}
{icon}
</View>
<View style={styles.layoutContent}>
<ExpandListPressable
hasMultipleAuthors={hasMultipleAuthors}
onToggleAuthorsExpanded={onToggleAuthorsExpanded}>
<CondensedAuthorsList
visible={!isAuthorsExpanded}
authors={authors}
onToggleAuthorsExpanded={onToggleAuthorsExpanded}
showDmButton={item.type === 'starterpack-joined'}
/>
<ExpandedAuthorsList visible={isAuthorsExpanded} authors={authors} />
<Text
style={[styles.meta, a.self_start, pal.text]}
accessibilityHint=""
accessibilityLabel={a11yLabel}>
{notificationContent}
<TimeElapsed timestamp={item.notification.indexedAt}>
{({timeElapsed}) => (
<>
{/* make sure there's whitespace around the middot -sfn */}
<Text style={[pal.textLight]}> &middot; </Text>
<Text style={[pal.textLight]} title={niceTimestamp}>
{timeElapsed}
</Text>
</>
)}
</TimeElapsed>
</Text>
</ExpandListPressable>
{item.type === 'post-like' || item.type === 'repost' ? (
<AdditionalPostText post={item.subject} />
) : null}
{item.type === 'feedgen-like' && item.subjectUri ? (
<FeedSourceCard
feedUri={item.subjectUri}
style={[
t.atoms.bg,
t.atoms.border_contrast_low,
a.border,
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,
}: {
hasMultipleAuthors: boolean
children: React.ReactNode
onToggleAuthorsExpanded: () => void
}) {
if (hasMultipleAuthors) {
return (
<Pressable
onPress={onToggleAuthorsExpanded}
style={[styles.expandedAuthorsTrigger]}
accessible={false}>
{children}
</Pressable>
)
} else {
return <>{children}</>
}
}
function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) {
const {_} = useLingui()
const agent = useAgent()
const navigation = useNavigation<NavigationProp>()
const [isLoading, setIsLoading] = React.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: () => void
showDmButton?: boolean
}) {
const pal = usePalette('default')
const {_} = useLingui()
if (!visible) {
return (
<View style={styles.avis}>
<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={[styles.expandedAuthorsCloseBtnIcon, pal.text]}
/>
<Text type="sm-medium" style={pal.text}>
<Trans context="action">Hide</Trans>
</Text>
</TouchableOpacity>
</View>
)
}
if (authors.length === 1) {
return (
<View style={[styles.avis]}>
<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={styles.avis}>
{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={[styles.aviExtraCount, pal.textLight]}>
+{authors.length - MAX_AUTHORS}
</Text>
) : undefined}
<ChevronDownIcon
size="md"
style={[styles.expandedAuthorsCloseBtnIcon, pal.textLight]}
/>
</View>
</TouchableOpacity>
)
}
function ExpandedAuthorsList({
visible,
authors,
}: {
visible: boolean
authors: Author[]
}) {
const {_} = useLingui()
const pal = usePalette('default')
const heightInterp = useAnimatedValue(visible ? 1 : 0)
const targetHeight =
authors.length * (EXPANDED_AUTHOR_EL_HEIGHT + 10) /*10=margin*/
const heightStyle = {
height: Animated.multiply(heightInterp, targetHeight),
}
useEffect(() => {
Animated.timing(heightInterp, {
toValue: visible ? 1 : 0,
duration: 200,
useNativeDriver: false,
}).start()
}, [heightInterp, visible])
return (
<Animated.View style={[a.overflow_hidden, heightStyle]}>
{visible &&
authors.map(author => (
<NewLink
key={author.profile.did}
label={author.profile.displayName || author.profile.handle}
accessibilityHint={_(msg`Opens this profile`)}
to={makeProfileLink({
did: author.profile.did,
handle: author.profile.handle,
})}
style={styles.expandedAuthor}>
<View style={styles.expandedAuthorAvi}>
<ProfileHoverCard did={author.profile.did}>
<UserAvatar
size={35}
avatar={author.profile.avatar}
moderation={author.moderation.ui('avatar')}
type={author.profile.associated?.labeler ? 'labeler' : 'user'}
/>
</ProfileHoverCard>
</View>
<View style={s.flex1}>
<Text
type="lg-bold"
numberOfLines={1}
style={pal.text}
lineHeight={1.2}>
<Text emoji type="lg-bold" style={pal.text} lineHeight={1.2}>
{sanitizeDisplayName(
author.profile.displayName || author.profile.handle,
)}
</Text>{' '}
<Text style={[pal.textLight]} lineHeight={1.2}>
{sanitizeHandle(author.profile.handle, '@')}
</Text>
</Text>
</View>
</NewLink>
))}
</Animated.View>
)
}
function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
const pal = usePalette('default')
if (post && AppBskyFeedPost.isRecord(post?.record)) {
const text = post.record.text
return (
<>
{text?.length > 0 && (
<Text emoji style={pal.textLight}>
{text}
</Text>
)}
<MediaPreview.Embed
embed={post.embed}
style={styles.additionalPostImages}
/>
</>
)
}
}
const styles = StyleSheet.create({
pointer: isWeb
? {
// @ts-ignore web only
cursor: 'pointer',
}
: {},
outer: {
padding: 10,
paddingRight: 15,
flexDirection: 'row',
},
layoutIcon: {
width: 60,
alignItems: 'flex-end',
paddingTop: 2,
},
icon: {
marginRight: 10,
marginTop: 4,
},
layoutContent: {
flex: 1,
},
avis: {
flexDirection: 'row',
alignItems: 'center',
},
aviExtraCount: {
fontWeight: '600',
paddingLeft: 6,
},
meta: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingTop: 6,
paddingBottom: 2,
},
postText: {
paddingBottom: 5,
color: colors.black,
},
additionalPostImages: {
marginTop: 5,
marginLeft: 2,
opacity: 0.8,
},
feedcard: {
borderRadius: 8,
paddingVertical: 12,
marginTop: 6,
},
addedContainer: {
paddingTop: 4,
paddingLeft: 36,
},
expandedAuthorsTrigger: {
zIndex: 1,
},
expandedAuthorsCloseBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: 10,
paddingBottom: 6,
},
expandedAuthorsCloseBtnIcon: {
marginLeft: 4,
marginRight: 4,
},
expandedAuthor: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
height: EXPANDED_AUTHOR_EL_HEIGHT,
},
expandedAuthorAvi: {
marginRight: 5,
},
})