From 8c8617d5f287a07addfe562594c183916e71d01b Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Wed, 10 Jun 2026 10:23:07 -0700 Subject: [PATCH] New layout for expanded notification bundles (#10664) Co-authored-by: Eric Bailey --- src/analytics/features/types.ts | 1 + .../notifications/NotificationFeedItem.tsx | 140 ++++++++++++++++-- 2 files changed, 127 insertions(+), 14 deletions(-) diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index c8a8cd72d9..e1d7527e27 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -14,6 +14,7 @@ export enum Features { DmsNewMessageComposerEnable = 'dms:new_message_composer:enable', ComposerLanguageDetectionEnable = 'composer:language_detection:enable', PostGalleryEmbedEnable = 'post_gallery_embed:enable', + NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable', AATest = 'aa-test', } diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index ac1488e4b3..7fc2dc2889 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -2,6 +2,7 @@ import {memo, useCallback, useEffect, useMemo, useState} from 'react' import { Animated, type GestureResponderEvent, + type LayoutChangeEvent, Pressable, StyleSheet, TouchableOpacity, @@ -62,11 +63,14 @@ 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 @@ -93,7 +97,12 @@ let NotificationFeedItem = ({ const queryClient = useQueryClient() const t = useTheme() const {_, i18n} = useLingui() + const ax = useAnalytics() + const profileCardEnabled = ax.features.enabled( + ax.features.NotificationsExpandedProfileCardEnable, + ) const [isAuthorsExpanded, setIsAuthorsExpanded] = useState(false) + const [isHoveringAuthorsList, setIsHoveringAuthorsList] = useState(false) const itemHref = useMemo(() => { switch (item.type) { case 'post-like': @@ -579,7 +588,11 @@ let NotificationFeedItem = ({ borderColor: t.palette.primary_100, }, !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} accessible={!isAuthorsExpanded} @@ -614,7 +627,9 @@ let NotificationFeedItem = ({ }}> {({hovered}) => ( <> - + {/* TODO: Prevent conditional rendering and move toward composable notifications for clearer accessibility labeling */} @@ -623,7 +638,17 @@ let NotificationFeedItem = ({ + onToggleAuthorsExpanded={onToggleAuthorsExpanded} + onHoverIn={ + profileCardEnabled + ? () => setIsHoveringAuthorsList(true) + : undefined + } + onHoverOut={ + profileCardEnabled + ? () => setIsHoveringAuthorsList(false) + : undefined + }> void + onHoverIn?: () => void + onHoverOut?: () => void }) { if (hasMultipleAuthors) { return ( {children} @@ -981,40 +1013,120 @@ function CondensedAuthorsList({ function ExpandedAuthorsList({ visible, authors, + moderationOpts, }: { visible: boolean 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 targetHeight = - authors.length * (EXPANDED_AUTHOR_EL_HEIGHT + 10) /*10=margin*/ - const heightStyle = { - height: Animated.multiply(heightInterp, targetHeight), - } + const opacityInterp = useAnimatedValue(visible ? 1 : 0) + const [measuredHeight, setMeasuredHeight] = useState(0) useEffect(() => { - Animated.timing(heightInterp, { + Animated.timing(isNativeFade ? opacityInterp : heightInterp, { toValue: visible ? 1 : 0, duration: 200, - useNativeDriver: false, + useNativeDriver: isNativeFade, }).start() - }, [heightInterp, visible]) + }, [heightInterp, opacityInterp, visible, isNativeFade]) + const onInnerLayout = (e: LayoutChangeEvent) => { + if (measuredHeight === 0) { + setMeasuredHeight(e.nativeEvent.layout.height) + } + } + if (isNativeFade) { + return ( + + {visible && ( + + {authors.map((author, i) => ( + + ))} + + )} + + ) + } + + 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 ( - {visible && + {profileCardEnabled ? ( + + {authors.map((author, i) => ( + + ))} + + ) : ( authors.map(author => ( - ))} + )) + )} ) } +function ExpandedAuthorProfileCard({ + author, + moderationOpts, + isLast, +}: { + author: Author + moderationOpts: ModerationOpts + isLast: boolean +}) { + return ( + + + + + + + + + + ) +} + function ExpandedAuthorCard({author}: {author: Author}) { const t = useTheme() const {_} = useLingui() return (