New layout for expanded notification bundles (#10664)

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Alex Benzer
2026-06-10 10:23:07 -07:00
committed by GitHub
parent 50dabf3f65
commit 8c8617d5f2
2 changed files with 127 additions and 14 deletions
+1
View File
@@ -14,6 +14,7 @@ export enum Features {
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable', DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable', ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable', PostGalleryEmbedEnable = 'post_gallery_embed:enable',
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
AATest = 'aa-test', AATest = 'aa-test',
} }
@@ -2,6 +2,7 @@ import {memo, useCallback, useEffect, useMemo, useState} from 'react'
import { import {
Animated, Animated,
type GestureResponderEvent, type GestureResponderEvent,
type LayoutChangeEvent,
Pressable, Pressable,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
@@ -62,11 +63,14 @@ import {VerifiedCheck} from '#/components/icons/VerifiedCheck'
import {InlineLinkText, Link} from '#/components/Link' import {InlineLinkText, Link} from '#/components/Link'
import * as MediaPreview from '#/components/MediaPreview' import * as MediaPreview from '#/components/MediaPreview'
import {ProfileBadges} from '#/components/ProfileBadges' import {ProfileBadges} from '#/components/ProfileBadges'
import * as ProfileCard from '#/components/ProfileCard'
import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {SubtleHover} from '#/components/SubtleHover' import {SubtleHover} from '#/components/SubtleHover'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
const MAX_AUTHORS = 5 const MAX_AUTHORS = 5
@@ -93,7 +97,12 @@ let NotificationFeedItem = ({
const queryClient = useQueryClient() const queryClient = useQueryClient()
const t = useTheme() const t = useTheme()
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const ax = useAnalytics()
const profileCardEnabled = ax.features.enabled(
ax.features.NotificationsExpandedProfileCardEnable,
)
const [isAuthorsExpanded, setIsAuthorsExpanded] = useState<boolean>(false) const [isAuthorsExpanded, setIsAuthorsExpanded] = useState<boolean>(false)
const [isHoveringAuthorsList, setIsHoveringAuthorsList] = useState(false)
const itemHref = useMemo(() => { const itemHref = useMemo(() => {
switch (item.type) { switch (item.type) {
case 'post-like': case 'post-like':
@@ -579,7 +588,11 @@ let NotificationFeedItem = ({
borderColor: t.palette.primary_100, borderColor: t.palette.primary_100,
}, },
!hideTopBorder && a.border_t, !hideTopBorder && a.border_t,
a.overflow_hidden, // 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} to={itemHref}
accessible={!isAuthorsExpanded} accessible={!isAuthorsExpanded}
@@ -614,7 +627,9 @@ let NotificationFeedItem = ({
}}> }}>
{({hovered}) => ( {({hovered}) => (
<> <>
<SubtleHover hover={hovered} /> <SubtleHover
hover={hovered && (!profileCardEnabled || !isHoveringAuthorsList)}
/>
<View style={[styles.layoutIcon, a.pr_sm]}> <View style={[styles.layoutIcon, a.pr_sm]}>
{/* TODO: Prevent conditional rendering and move toward composable {/* TODO: Prevent conditional rendering and move toward composable
notifications for clearer accessibility labeling */} notifications for clearer accessibility labeling */}
@@ -623,7 +638,17 @@ let NotificationFeedItem = ({
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
<ExpandListPressable <ExpandListPressable
hasMultipleAuthors={hasMultipleAuthors} hasMultipleAuthors={hasMultipleAuthors}
onToggleAuthorsExpanded={onToggleAuthorsExpanded}> onToggleAuthorsExpanded={onToggleAuthorsExpanded}
onHoverIn={
profileCardEnabled
? () => setIsHoveringAuthorsList(true)
: undefined
}
onHoverOut={
profileCardEnabled
? () => setIsHoveringAuthorsList(false)
: undefined
}>
<CondensedAuthorsList <CondensedAuthorsList
visible={!isAuthorsExpanded} visible={!isAuthorsExpanded}
authors={authors} authors={authors}
@@ -633,6 +658,7 @@ let NotificationFeedItem = ({
<ExpandedAuthorsList <ExpandedAuthorsList
visible={isAuthorsExpanded} visible={isAuthorsExpanded}
authors={authors} authors={authors}
moderationOpts={moderationOpts}
/> />
<Text <Text
style={[ style={[
@@ -719,15 +745,21 @@ function ExpandListPressable({
hasMultipleAuthors, hasMultipleAuthors,
children, children,
onToggleAuthorsExpanded, onToggleAuthorsExpanded,
onHoverIn,
onHoverOut,
}: { }: {
hasMultipleAuthors: boolean hasMultipleAuthors: boolean
children: React.ReactNode children: React.ReactNode
onToggleAuthorsExpanded: (e: GestureResponderEvent) => void onToggleAuthorsExpanded: (e: GestureResponderEvent) => void
onHoverIn?: () => void
onHoverOut?: () => void
}) { }) {
if (hasMultipleAuthors) { if (hasMultipleAuthors) {
return ( return (
<Pressable <Pressable
onPress={onToggleAuthorsExpanded} onPress={onToggleAuthorsExpanded}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
style={[styles.expandedAuthorsTrigger]} style={[styles.expandedAuthorsTrigger]}
accessible={false}> accessible={false}>
{children} {children}
@@ -981,40 +1013,120 @@ function CondensedAuthorsList({
function ExpandedAuthorsList({ function ExpandedAuthorsList({
visible, visible,
authors, authors,
moderationOpts,
}: { }: {
visible: boolean visible: boolean
authors: Author[] authors: Author[]
moderationOpts: ModerationOpts
}) { }) {
const ax = useAnalytics()
const profileCardEnabled = ax.features.enabled(
ax.features.NotificationsExpandedProfileCardEnable,
)
const isNativeFade = !IS_WEB && profileCardEnabled
const heightInterp = useAnimatedValue(visible ? 1 : 0) const heightInterp = useAnimatedValue(visible ? 1 : 0)
const targetHeight = const opacityInterp = useAnimatedValue(visible ? 1 : 0)
authors.length * (EXPANDED_AUTHOR_EL_HEIGHT + 10) /*10=margin*/ const [measuredHeight, setMeasuredHeight] = useState(0)
const heightStyle = {
height: Animated.multiply(heightInterp, targetHeight),
}
useEffect(() => { useEffect(() => {
Animated.timing(heightInterp, { Animated.timing(isNativeFade ? opacityInterp : heightInterp, {
toValue: visible ? 1 : 0, toValue: visible ? 1 : 0,
duration: 200, duration: 200,
useNativeDriver: false, useNativeDriver: isNativeFade,
}).start() }).start()
}, [heightInterp, visible]) }, [heightInterp, opacityInterp, visible, isNativeFade])
const onInnerLayout = (e: LayoutChangeEvent) => {
if (measuredHeight === 0) {
setMeasuredHeight(e.nativeEvent.layout.height)
}
}
if (isNativeFade) {
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 targetHeight = profileCardEnabled
? measuredHeight
: authors.length * (EXPANDED_AUTHOR_EL_HEIGHT + 10) /*10=margin*/
const heightStyle = {
height: Animated.multiply(heightInterp, targetHeight),
opacity: Animated.divide(heightInterp, 1),
}
return ( return (
<Animated.View style={[a.overflow_hidden, heightStyle]}> <Animated.View style={[a.overflow_hidden, heightStyle]}>
{visible && {profileCardEnabled ? (
<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>
) : (
authors.map(author => ( authors.map(author => (
<ExpandedAuthorCard key={author.profile.did} author={author} /> <ExpandedAuthorCard key={author.profile.did} author={author} />
))} ))
)}
</Animated.View> </Animated.View>
) )
} }
function ExpandedAuthorProfileCard({
author,
moderationOpts,
isLast,
}: {
author: Author
moderationOpts: ModerationOpts
isLast: boolean
}) {
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="ProfileCard"
/>
</ProfileCard.Header>
</ProfileCard.Outer>
</ProfileCard.Link>
)
}
function ExpandedAuthorCard({author}: {author: Author}) { function ExpandedAuthorCard({author}: {author: Author}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
return ( return (
<Link <Link
key={author.profile.did}
label={author.profile.displayName || author.profile.handle} label={author.profile.displayName || author.profile.handle}
accessibilityHint={_(msg`Opens this profile`)} accessibilityHint={_(msg`Opens this profile`)}
to={makeProfileLink({ to={makeProfileLink({