New layout for expanded notification bundles (#10664)
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user