Fix nested links and hover states

This commit is contained in:
Eric Bailey
2026-05-25 15:13:45 -05:00
parent 5060114d7f
commit c23e6c36b0
3 changed files with 280 additions and 284 deletions
+58 -20
View File
@@ -1,5 +1,10 @@
import {useCallback, useMemo} from 'react' import {useCallback, useMemo} from 'react'
import {type GestureResponderEvent, Linking} from 'react-native' import {
type GestureResponderEvent,
Linking,
type NativeSyntheticEvent,
type TargetedEvent,
} from 'react-native'
import {sanitizeUrl} from '@braintree/sanitize-url' import {sanitizeUrl} from '@braintree/sanitize-url'
import { import {
type LinkProps as RNLinkProps, type LinkProps as RNLinkProps,
@@ -78,6 +83,15 @@ type BaseLinkProps = {
* Whether the link should be opened through the redirect proxy. * Whether the link should be opened through the redirect proxy.
*/ */
shouldProxy?: boolean shouldProxy?: boolean
/**
* Web only
*/
onMouseEnter?: () => void
/**
* Web only
*/
onMouseLeave?: () => void
} }
export function useLink({ export function useLink({
@@ -322,12 +336,10 @@ export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps & BaseLinkProps &
TextStyleProp & TextStyleProp &
Pick<TextProps, 'selectable' | 'numberOfLines' | 'emoji'> & Pick<TextProps, 'selectable' | 'numberOfLines' | 'emoji'> &
Pick<ButtonProps, 'label' | 'accessibilityHint'> & { Pick<ButtonProps, 'label' | 'accessibilityHint' | 'onFocus' | 'onBlur'> & {
disableUnderline?: boolean disableUnderline?: boolean
title?: TextProps['title'] title?: TextProps['title']
overridePresentation?: boolean overridePresentation?: boolean
onMouseEnter?: () => void
onMouseLeave?: () => void
} }
> >
@@ -362,9 +374,9 @@ export function InlineLinkText({
shouldProxy: shouldProxy, shouldProxy: shouldProxy,
}) })
const { const {
state: hovered, state: interacted,
onIn: onHoverIn, onIn: onInteract,
onOut: onHoverOut, onOut: onInteractOut,
} = useInteractionState() } = useInteractionState()
const flattenedStyle = flatten(style) || {} const flattenedStyle = flatten(style) || {}
@@ -376,7 +388,7 @@ export function InlineLinkText({
{...rest} {...rest}
style={[ style={[
{color: t.palette.primary_500}, {color: t.palette.primary_500},
hovered && interacted &&
!disableUnderline && { !disableUnderline && {
...web({ ...web({
outline: 0, outline: 0,
@@ -390,13 +402,23 @@ export function InlineLinkText({
role="link" role="link"
onPress={download ? undefined : onPress} onPress={download ? undefined : onPress}
onLongPress={onLongPress} onLongPress={onLongPress}
onMouseEnter={() => { {...web({
rest.onMouseEnter?.() onMouseEnter: () => {
onHoverIn() rest.onMouseEnter?.()
onInteract()
},
onMouseLeave: () => {
rest.onMouseLeave?.()
onInteractOut()
},
})}
onFocus={(e: NativeSyntheticEvent<TargetedEvent>) => {
rest.onFocus?.(e)
onInteract()
}} }}
onMouseLeave={() => { onBlur={(e: NativeSyntheticEvent<TargetedEvent>) => {
rest.onMouseLeave?.() rest.onBlur?.(e)
onHoverOut() onInteractOut()
}} }}
accessibilityRole="link" accessibilityRole="link"
href={href} href={href}
@@ -444,9 +466,9 @@ export function SimpleInlineLinkText({
}) { }) {
const t = useTheme() const t = useTheme()
const { const {
state: hovered, state: interacted,
onIn: onHoverIn, onIn: onInteract,
onOut: onHoverOut, onOut: onInteractOut,
} = useInteractionState() } = useInteractionState()
const flattenedStyle = flatten(style) || {} const flattenedStyle = flatten(style) || {}
const isExternal = isExternalUrl(to) const isExternal = isExternalUrl(to)
@@ -470,7 +492,7 @@ export function SimpleInlineLinkText({
{...rest} {...rest}
style={[ style={[
{color: t.palette.primary_500}, {color: t.palette.primary_500},
hovered && interacted &&
!disableUnderline && { !disableUnderline && {
...web({ ...web({
outline: 0, outline: 0,
@@ -483,8 +505,24 @@ export function SimpleInlineLinkText({
]} ]}
role="link" role="link"
onPress={onPress} onPress={onPress}
onMouseEnter={onHoverIn} {...web({
onMouseLeave={onHoverOut} onMouseEnter: () => {
rest.onMouseEnter?.()
onInteract()
},
onMouseLeave: () => {
rest.onMouseLeave?.()
onInteractOut()
},
})}
onFocus={(e: NativeSyntheticEvent<TargetedEvent>) => {
rest.onFocus?.(e)
onInteract()
}}
onBlur={(e: NativeSyntheticEvent<TargetedEvent>) => {
rest.onBlur?.(e)
onInteractOut()
}}
accessibilityRole="link" accessibilityRole="link"
href={href} href={href}
{...web({ {...web({
@@ -21,13 +21,9 @@ import {Text} from '#/components/Typography'
export function StandardSiteMetaRow({ export function StandardSiteMetaRow({
type = 'document', type = 'document',
view, view,
onInteractWithin,
onInteractWithout,
}: { }: {
type?: 'document' | 'publication' type?: 'document' | 'publication'
view: AppBskyEmbedExternal.ViewExternal view: AppBskyEmbedExternal.ViewExternal
onInteractWithin: () => void
onInteractWithout: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -80,13 +76,7 @@ export function StandardSiteMetaRow({
<InlineLinkText <InlineLinkText
label={l`View @${authorProfile.handle}'s profile`} label={l`View @${authorProfile.handle}'s profile`}
to={makeProfileLink(authorProfile)} to={makeProfileLink(authorProfile)}
style={metaTextStyle} style={[metaTextStyle, a.pointer_events_auto]}>
onPress={e => {
// this link is nested, yes it's not ideal
e.stopPropagation()
}}
onMouseEnter={onInteractWithin}
onMouseLeave={onInteractWithout}>
@{authorProfile.handle} @{authorProfile.handle}
</InlineLinkText> </InlineLinkText>
</Trans> </Trans>
@@ -98,7 +88,7 @@ export function StandardSiteMetaRow({
if (items.length === 0) return null if (items.length === 0) return null
return ( return (
<View style={[a.flex_row, a.align_center, a.gap_xs]}> <View style={[a.flex_row, a.align_center, a.gap_xs, a.z_10]}>
{items.map((item, i) => ( {items.map((item, i) => (
<Fragment key={item.key}> <Fragment key={item.key}>
{i > 0 && <Text style={metaTextStyle}></Text>} {i > 0 && <Text style={metaTextStyle}></Text>}
@@ -9,7 +9,7 @@ import {shareUrl} from '#/lib/sharing'
import {niceDate} from '#/lib/strings/time' import {niceDate} from '#/lib/strings/time'
import {toNiceDomain} from '#/lib/strings/url-helpers' import {toNiceDomain} from '#/lib/strings/url-helpers'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, utils} from '#/alf' import {atoms as a, useBreakpoints, useTheme, utils, web} from '#/alf'
import {ButtonIcon, ButtonText} from '#/components/Button' import {ButtonIcon, ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider' import {Divider} from '#/components/Divider'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
@@ -73,9 +73,9 @@ export const StandardSiteEmbed = ({
} }
const { const {
state: interactedWithin, state: interacted,
onIn: onInteractWithin, onIn: onInteract,
onOut: onInteractWithout, onOut: onInteractOut,
} = useInteractionState() } = useInteractionState()
const onPress = () => { const onPress = () => {
@@ -111,7 +111,7 @@ export const StandardSiteEmbed = ({
a.overflow_hidden, a.overflow_hidden,
a.w_full, a.w_full,
a.border, a.border,
t.atoms.border_contrast_low, interacted ? t.atoms.border_contrast_high : t.atoms.border_contrast_low,
style, style,
]}> ]}>
<Link <Link
@@ -119,124 +119,108 @@ export const StandardSiteEmbed = ({
to={view.uri} to={view.uri}
label={view.title || l`Open link to ${niceUrl}`} label={view.title || l`Open link to ${niceUrl}`}
onPress={onPress} onPress={onPress}
onLongPress={onLongPress}> onLongPress={onLongPress}
{({hovered: maybeHovered}) => { style={[a.absolute, a.inset_0, a.z_10]}
const hovered = maybeHovered && !interactedWithin {...web({
return ( onMouseEnter: onInteract,
<View style={[a.w_full]}> onMouseLeave: onInteractOut,
{imageUri ? ( })}
<Image onFocus={onInteract}
style={[a.aspect_card]} onBlur={onInteractOut}>
source={{uri: imageUri}} <></>
accessibilityIgnoresInvertColors </Link>
loading="lazy"
/>
) : undefined}
<View style={[a.w_full, a.z_10, a.pointer_events_none]}>
{imageUri ? (
<Image
style={[a.aspect_card]}
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
loading="lazy"
/>
) : undefined}
<View
style={[
a.flex_1,
a.pt_sm,
hasMedia && a.border_t,
interacted
? t.atoms.border_contrast_high
: t.atoms.border_contrast_low,
{gap: 3},
isStandard && a.pt_md,
]}>
<View
style={[
a.pb_xs,
a.px_md,
{gap: 3},
isStandard && [{gap: 5}, a.pb_sm],
]}>
<Text
emoji
numberOfLines={3}
style={[
a.text_md,
a.font_semi_bold,
a.leading_snug,
isStandard && [a.text_lg, a.font_bold],
]}>
{view.title}
</Text>
{view.description ? (
<Text
emoji
numberOfLines={view.thumb ? 2 : 4}
style={[a.text_sm, a.leading_snug]}>
{view.description}
</Text>
) : undefined}
{isStandard && (view.createdAt || view.readingTime) && (
<View <View
style={[ style={[a.flex_row, a.align_center, a.gap_md, {paddingTop: 2}]}>
a.flex_1, {view.createdAt && (
a.pt_sm,
t.atoms.border_contrast_low,
hasMedia && a.border_t,
{gap: 3},
isStandard && a.pt_md,
]}>
<View
style={[
a.pb_xs,
a.px_md,
{gap: 3},
isStandard && [{gap: 5}, a.pb_sm],
]}>
<Text <Text
emoji
numberOfLines={3}
style={[ style={[
a.text_md, a.text_xs,
a.font_semi_bold,
a.leading_snug, a.leading_snug,
isStandard && [ t.atoms.text_contrast_medium,
a.text_lg,
a.font_bold,
hovered && a.underline,
],
]}> ]}>
{view.title} {niceDate(i18n, view.createdAt, 'long', 'none')}
</Text> </Text>
{view.description ? ( )}
{view.readingTime && (
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<Clock size="xs" style={t.atoms.text_contrast_medium} />
<Text <Text
emoji
numberOfLines={view.thumb ? 2 : 4}
style={[a.text_sm, a.leading_snug]}>
{view.description}
</Text>
) : undefined}
{isStandard && (view.createdAt || view.readingTime) && (
<View
style={[ style={[
a.flex_row, a.text_xs,
a.align_center, a.leading_snug,
a.gap_md, t.atoms.text_contrast_medium,
{paddingTop: 2},
]}> ]}>
{view.createdAt && ( {l({
<Text message: plural(view.readingTime, {
style={[ one: '#m',
a.text_xs, other: '#m',
a.leading_snug, }),
t.atoms.text_contrast_medium, comment: `How long it takes to read an article, in minutes. Displayed in a short form, e.g. "5m" for 5 minutes.`,
]}> })}
{niceDate(i18n, view.createdAt, 'long', 'none')} </Text>
</Text>
)}
{view.readingTime && (
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<Clock size="xs" style={t.atoms.text_contrast_high} />
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{l({
message: plural(view.readingTime, {
one: '#m',
other: '#m',
}),
comment: `How long it takes to read an article, in minutes. Displayed in a short form, e.g. "5m" for 5 minutes.`,
})}
</Text>
</View>
)}
</View>
)}
</View>
{!view.source && (
<View style={[a.px_md]}>
<Divider />
<View style={[a.py_sm]}>
<StandardSiteMetaRow
view={view}
onInteractWithin={onInteractWithin}
onInteractWithout={onInteractWithout}
/>
</View>
</View> </View>
)} )}
</View> </View>
</View> )}
)
}}
</Link>
{view.source && (
<>
<View style={[a.px_md]}>
<Divider />
</View> </View>
</View>
</View>
<View style={[a.z_20]}>
<View style={[a.px_md]}>
<Divider />
</View>
{view.source ? (
<PublicationFooter <PublicationFooter
view={view} view={view}
onPress={onPress} onPress={onPress}
@@ -244,8 +228,12 @@ export const StandardSiteEmbed = ({
themeColors={themeColors} themeColors={themeColors}
hideSubscribe={hideSubscribe} hideSubscribe={hideSubscribe}
/> />
</> ) : (
)} <View style={[a.px_md, a.py_sm, a.pointer_events_none]}>
<StandardSiteMetaRow view={view} />
</View>
)}
</View>
</View> </View>
) )
} }
@@ -269,109 +257,109 @@ export function PublicationCard({
const {t: l} = useLingui() const {t: l} = useLingui()
const {gtPhone} = useBreakpoints() const {gtPhone} = useBreakpoints()
const { const {
state: interactedWithin, state: interacted,
onIn: onInteractWithin, onIn: onInteract,
onOut: onInteractWithout, onOut: onInteractOut,
} = useInteractionState() } = useInteractionState()
if (!view.source) return null if (!view.source) return null
return ( return (
<Link <View
shouldProxy style={[
to={view.source.uri} a.rounded_lg,
label={l`View publication`} a.overflow_hidden,
onPress={onPress} a.w_full,
onLongPress={onLongPress}> a.border,
{({hovered: maybeHovered}) => { a.p_md,
const hovered = maybeHovered && !interactedWithin interacted ? t.atoms.border_contrast_high : t.atoms.border_contrast_low,
return ( style,
<View ]}>
style={[ <Link
a.flex_col, shouldProxy
a.rounded_lg, to={view.source.uri}
a.overflow_hidden, label={l`View publication`}
a.w_full, onPress={onPress}
a.border, onLongPress={onLongPress}
a.p_md, {...web({
t.atoms.border_contrast_low, onMouseEnter: onInteract,
style, onMouseLeave: onInteractOut,
]}> })}
<View onFocus={onInteract}
onBlur={onInteractOut}
style={[a.absolute, a.inset_0]}>
<></>
</Link>
<View
style={[
a.flex_1,
a.align_center,
a.justify_between,
a.gap_md,
a.pointer_events_none,
gtPhone && [a.flex_row, a.gap_sm],
]}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_sm,
gtPhone && a.flex_1,
]}>
<PublicationIcon
view={view}
size={40}
interacted={interacted}
themeColors={themeColors}
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
numberOfLines={1}
style={[ style={[
a.flex_1, a.text_md,
a.align_center, a.font_semi_bold,
a.justify_between, t.atoms.text,
a.gap_md, interacted && a.underline,
gtPhone && [a.flex_row, a.gap_sm], ]}>
]} {view.source?.title}
testID="publication-embed-footer"> </Text>
<View <StandardSiteMetaRow type="publication" view={view} />
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_sm,
gtPhone && a.flex_1,
]}>
<PublicationIcon
view={view}
size={40}
hovered={hovered}
themeColors={themeColors}
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
numberOfLines={1}
style={[
a.text_md,
a.font_semi_bold,
t.atoms.text,
hovered && a.underline,
]}>
{view.source?.title}
</Text>
<StandardSiteMetaRow
type="publication"
view={view}
onInteractWithin={onInteractWithin}
onInteractWithout={onInteractWithout}
/>
</View>
</View>
{!hideSubscribe && gtPhone && (
<SubscribeButton
view={view}
style={[!gtPhone && [a.w_full, a.justify_center]]}
onPress={onPress}
onLongPress={onLongPress}
/>
)}
</View>
{view.description && (
<View style={[a.pt_sm]}>
<Text style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
{view.description}
</Text>
</View>
)}
{!hideSubscribe && !gtPhone && (
<View style={[view.description && a.pt_sm]}>
<SubscribeButton
view={view}
style={[!gtPhone && [a.w_full, a.justify_center]]}
onPress={onPress}
onLongPress={onLongPress}
/>
</View>
)}
</View> </View>
) </View>
}}
</Link> {!hideSubscribe && gtPhone && (
<SubscribeButton
view={view}
style={[!gtPhone && [a.w_full, a.justify_center]]}
onPress={onPress}
onLongPress={onLongPress}
/>
)}
</View>
<View style={[a.pointer_events_none]}>
{view.description && (
<View style={[a.pt_sm]}>
<Text style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
{view.description}
</Text>
</View>
)}
{!hideSubscribe && !gtPhone && (
<View style={[view.description && a.pt_sm]}>
<SubscribeButton
view={view}
style={[!gtPhone && [a.w_full, a.justify_center]]}
onPress={onPress}
onLongPress={onLongPress}
/>
</View>
)}
</View>
</View>
) )
} }
@@ -402,7 +390,7 @@ export function SubscribeButton({
label={cta} label={cta}
size="small" size="small"
color="secondary_inverted" color="secondary_inverted"
style={[style, a.gap_sm]} style={[style, a.gap_sm, a.pointer_events_auto]}
onPress={onPress} onPress={onPress}
onLongPress={onLongPress}> onLongPress={onLongPress}>
{highlightedPublisher ? ( {highlightedPublisher ? (
@@ -424,15 +412,13 @@ export function SubscribeButton({
function PublicationIcon({ function PublicationIcon({
view, view,
size, size,
hovered,
themeColors, themeColors,
}: { }: {
view: AppBskyEmbedExternal.ViewExternal view: AppBskyEmbedExternal.ViewExternal
size: number size: number
hovered?: boolean interacted?: boolean
themeColors: ThemeColors themeColors: ThemeColors
}) { }) {
const opacity = hovered ? 0.6 : 0.2
if (!view.source) return null if (!view.source) return null
return view.source?.icon ? ( return view.source?.icon ? (
<View> <View>
@@ -443,15 +429,7 @@ function PublicationIcon({
avatar={view.source.icon} avatar={view.source.icon}
extraAviStyle={PUBLICATION_AVATAR_STYLE} extraAviStyle={PUBLICATION_AVATAR_STYLE}
/> />
<MediaInsetBorder <MediaInsetBorder opaque style={[a.rounded_sm]} />
style={[
a.rounded_sm,
{
borderColor: themeColors.accentForeground,
opacity,
},
]}
/>
</View> </View>
) : ( ) : (
<View <View
@@ -470,15 +448,7 @@ function PublicationIcon({
style={[a.text_xl, a.font_bold, {color: themeColors.accentForeground}]}> style={[a.text_xl, a.font_bold, {color: themeColors.accentForeground}]}>
{[...view.source.title][0] ?? ''} {[...view.source.title][0] ?? ''}
</Text> </Text>
<MediaInsetBorder <MediaInsetBorder opaque style={[a.rounded_sm]} />
style={[
a.rounded_sm,
{
borderColor: themeColors.accentForeground,
opacity,
},
]}
/>
</View> </View>
) )
} }
@@ -500,16 +470,10 @@ export function PublicationFooter({
const {t: l} = useLingui() const {t: l} = useLingui()
const {gtPhone} = useBreakpoints() const {gtPhone} = useBreakpoints()
const { const {
state: maybeHovered, state: interacted,
onIn: onHoverIn, onIn: onInteract,
onOut: onHoverOut, onOut: onInteractOut,
} = useInteractionState() } = useInteractionState()
const {
state: interactedWithin,
onIn: onInteractWithin,
onOut: onInteractWithout,
} = useInteractionState()
const hovered = maybeHovered && !interactedWithin
if (!view.source) return null if (!view.source) return null
@@ -523,27 +487,36 @@ export function PublicationFooter({
a.gap_md, a.gap_md,
gtPhone && [a.flex_row, a.gap_sm], gtPhone && [a.flex_row, a.gap_sm],
]} ]}
testID="publication-embed-footer" testID="publication-embed-footer">
// @ts-ignore it's Fine™
onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}>
<Link <Link
shouldProxy shouldProxy
to={view.source.uri} to={view.source.uri}
label={l`View publication`} label={l`View publication`}
onPress={onPress} onPress={onPress}
onLongPress={onLongPress} onLongPress={onLongPress}
style={[a.absolute, a.inset_0, web({outline: 0})]}
{...web({
onMouseEnter: onInteract,
onMouseLeave: onInteractOut,
})}
onFocus={onInteract}
onBlur={onInteractOut}>
<></>
</Link>
<View
style={[ style={[
a.w_full, a.w_full,
a.flex_row, a.flex_row,
a.align_center, a.align_center,
a.gap_sm, a.gap_sm,
gtPhone && a.flex_1, gtPhone && a.flex_1,
a.pointer_events_none,
]}> ]}>
<PublicationIcon <PublicationIcon
view={view} view={view}
size={32} size={32}
hovered={hovered} interacted={interacted}
themeColors={themeColors} themeColors={themeColors}
/> />
<View style={[a.flex_1, a.gap_2xs]}> <View style={[a.flex_1, a.gap_2xs]}>
@@ -553,23 +526,18 @@ export function PublicationFooter({
a.text_sm, a.text_sm,
a.font_medium, a.font_medium,
t.atoms.text, t.atoms.text,
hovered && a.underline, interacted && a.underline,
]}> ]}>
{view.source?.title} {view.source?.title}
</Text> </Text>
<StandardSiteMetaRow <StandardSiteMetaRow type="publication" view={view} />
type="publication"
view={view}
onInteractWithin={onInteractWithin}
onInteractWithout={onInteractWithout}
/>
</View> </View>
</Link> </View>
{!hideSubscribe && ( {!hideSubscribe && (
<SubscribeButton <SubscribeButton
view={view} view={view}
style={[!gtPhone && [a.w_full, a.justify_center]]} style={[a.z_10, !gtPhone && [a.w_full, a.justify_center]]}
onPress={onPress} onPress={onPress}
onLongPress={onLongPress} onLongPress={onLongPress}
/> />