Standard Site link card improvements (#10608)

This commit is contained in:
Eric Bailey
2026-05-25 15:49:30 -05:00
committed by GitHub
parent 709589fa36
commit f5dcce1718
3 changed files with 290 additions and 286 deletions
+58 -20
View File
@@ -1,5 +1,10 @@
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 {
type LinkProps as RNLinkProps,
@@ -78,6 +83,15 @@ type BaseLinkProps = {
* Whether the link should be opened through the redirect proxy.
*/
shouldProxy?: boolean
/**
* Web only
*/
onMouseEnter?: () => void
/**
* Web only
*/
onMouseLeave?: () => void
}
export function useLink({
@@ -322,12 +336,10 @@ export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps &
TextStyleProp &
Pick<TextProps, 'selectable' | 'numberOfLines' | 'emoji'> &
Pick<ButtonProps, 'label' | 'accessibilityHint'> & {
Pick<ButtonProps, 'label' | 'accessibilityHint' | 'onFocus' | 'onBlur'> & {
disableUnderline?: boolean
title?: TextProps['title']
overridePresentation?: boolean
onMouseEnter?: () => void
onMouseLeave?: () => void
}
>
@@ -362,9 +374,9 @@ export function InlineLinkText({
shouldProxy: shouldProxy,
})
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
state: interacted,
onIn: onInteract,
onOut: onInteractOut,
} = useInteractionState()
const flattenedStyle = flatten(style) || {}
@@ -376,7 +388,7 @@ export function InlineLinkText({
{...rest}
style={[
{color: t.palette.primary_500},
hovered &&
interacted &&
!disableUnderline && {
...web({
outline: 0,
@@ -390,13 +402,23 @@ export function InlineLinkText({
role="link"
onPress={download ? undefined : onPress}
onLongPress={onLongPress}
onMouseEnter={() => {
rest.onMouseEnter?.()
onHoverIn()
{...web({
onMouseEnter: () => {
rest.onMouseEnter?.()
onInteract()
},
onMouseLeave: () => {
rest.onMouseLeave?.()
onInteractOut()
},
})}
onFocus={(e: NativeSyntheticEvent<TargetedEvent>) => {
rest.onFocus?.(e)
onInteract()
}}
onMouseLeave={() => {
rest.onMouseLeave?.()
onHoverOut()
onBlur={(e: NativeSyntheticEvent<TargetedEvent>) => {
rest.onBlur?.(e)
onInteractOut()
}}
accessibilityRole="link"
href={href}
@@ -444,9 +466,9 @@ export function SimpleInlineLinkText({
}) {
const t = useTheme()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
state: interacted,
onIn: onInteract,
onOut: onInteractOut,
} = useInteractionState()
const flattenedStyle = flatten(style) || {}
const isExternal = isExternalUrl(to)
@@ -470,7 +492,7 @@ export function SimpleInlineLinkText({
{...rest}
style={[
{color: t.palette.primary_500},
hovered &&
interacted &&
!disableUnderline && {
...web({
outline: 0,
@@ -483,8 +505,24 @@ export function SimpleInlineLinkText({
]}
role="link"
onPress={onPress}
onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}
{...web({
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"
href={href}
{...web({
@@ -21,13 +21,9 @@ import {Text} from '#/components/Typography'
export function StandardSiteMetaRow({
type = 'document',
view,
onInteractWithin,
onInteractWithout,
}: {
type?: 'document' | 'publication'
view: AppBskyEmbedExternal.ViewExternal
onInteractWithin: () => void
onInteractWithout: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
@@ -80,13 +76,7 @@ export function StandardSiteMetaRow({
<InlineLinkText
label={l`View @${authorProfile.handle}'s profile`}
to={makeProfileLink(authorProfile)}
style={metaTextStyle}
onPress={e => {
// this link is nested, yes it's not ideal
e.stopPropagation()
}}
onMouseEnter={onInteractWithin}
onMouseLeave={onInteractWithout}>
style={[metaTextStyle, a.pointer_events_auto]}>
@{authorProfile.handle}
</InlineLinkText>
</Trans>
@@ -98,7 +88,7 @@ export function StandardSiteMetaRow({
if (items.length === 0) return null
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) => (
<Fragment key={item.key}>
{i > 0 && <Text style={metaTextStyle}></Text>}
@@ -9,7 +9,7 @@ import {shareUrl} from '#/lib/sharing'
import {niceDate} from '#/lib/strings/time'
import {toNiceDomain} from '#/lib/strings/url-helpers'
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 {Divider} from '#/components/Divider'
import {useInteractionState} from '#/components/hooks/useInteractionState'
@@ -73,9 +73,9 @@ export const StandardSiteEmbed = ({
}
const {
state: interactedWithin,
onIn: onInteractWithin,
onOut: onInteractWithout,
state: interacted,
onIn: onInteract,
onOut: onInteractOut,
} = useInteractionState()
const onPress = () => {
@@ -111,7 +111,7 @@ export const StandardSiteEmbed = ({
a.overflow_hidden,
a.w_full,
a.border,
t.atoms.border_contrast_low,
interacted ? t.atoms.border_contrast_high : t.atoms.border_contrast_low,
style,
]}>
<Link
@@ -119,124 +119,108 @@ export const StandardSiteEmbed = ({
to={view.uri}
label={view.title || l`Open link to ${niceUrl}`}
onPress={onPress}
onLongPress={onLongPress}>
{({hovered: maybeHovered}) => {
const hovered = maybeHovered && !interactedWithin
return (
<View style={[a.w_full]}>
{imageUri ? (
<Image
style={[a.aspect_card]}
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
loading="lazy"
/>
) : undefined}
onLongPress={onLongPress}
style={[a.absolute, a.inset_0, a.z_10]}
{...web({
onMouseEnter: onInteract,
onMouseLeave: onInteractOut,
})}
onFocus={onInteract}
onBlur={onInteractOut}>
<></>
</Link>
<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
style={[
a.flex_1,
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],
]}>
style={[a.flex_row, a.align_center, a.gap_md, {paddingTop: 2}]}>
{view.createdAt && (
<Text
emoji
numberOfLines={3}
style={[
a.text_md,
a.font_semi_bold,
a.text_xs,
a.leading_snug,
isStandard && [
a.text_lg,
a.font_bold,
hovered && a.underline,
],
t.atoms.text_contrast_medium,
]}>
{view.title}
{niceDate(i18n, view.createdAt, 'long', 'none')}
</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
emoji
numberOfLines={view.thumb ? 2 : 4}
style={[a.text_sm, a.leading_snug]}>
{view.description}
</Text>
) : undefined}
{isStandard && (view.createdAt || view.readingTime) && (
<View
style={[
a.flex_row,
a.align_center,
a.gap_md,
{paddingTop: 2},
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{view.createdAt && (
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_high,
]}>
{niceDate(i18n, view.createdAt, 'medium', 'none')}
</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_high,
]}>
{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>
{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>
)
}}
</Link>
{view.source && (
<>
<View style={[a.px_md]}>
<Divider />
)}
</View>
</View>
</View>
<View style={[a.z_20]}>
<View style={[a.px_md]}>
<Divider />
</View>
{view.source ? (
<PublicationFooter
view={view}
onPress={onPress}
@@ -244,8 +228,12 @@ export const StandardSiteEmbed = ({
themeColors={themeColors}
hideSubscribe={hideSubscribe}
/>
</>
)}
) : (
<View style={[a.px_md, a.py_sm, a.pointer_events_none]}>
<StandardSiteMetaRow view={view} />
</View>
)}
</View>
</View>
)
}
@@ -269,109 +257,106 @@ export function PublicationCard({
const {t: l} = useLingui()
const {gtPhone} = useBreakpoints()
const {
state: interactedWithin,
onIn: onInteractWithin,
onOut: onInteractWithout,
state: interacted,
onIn: onInteract,
onOut: onInteractOut,
} = useInteractionState()
if (!view.source) return null
return (
<Link
shouldProxy
to={view.source.uri}
label={l`View publication`}
onPress={onPress}
onLongPress={onLongPress}>
{({hovered: maybeHovered}) => {
const hovered = maybeHovered && !interactedWithin
return (
<View
style={[
a.flex_col,
a.rounded_lg,
a.overflow_hidden,
a.w_full,
a.border,
a.p_md,
t.atoms.border_contrast_low,
style,
]}>
<View
style={[
a.flex_1,
a.align_center,
a.justify_between,
a.gap_md,
gtPhone && [a.flex_row, a.gap_sm],
]}
testID="publication-embed-footer">
<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>
<View
style={[
a.rounded_lg,
a.overflow_hidden,
a.w_full,
a.border,
a.p_md,
interacted ? t.atoms.border_contrast_high : t.atoms.border_contrast_low,
style,
]}>
<Link
shouldProxy
to={view.source.uri}
label={
view.source.title ? l`View ${view.source.title}` : l`View publication`
}
onPress={onPress}
onLongPress={onLongPress}
{...web({
onMouseEnter: onInteract,
onMouseLeave: onInteractOut,
})}
onFocus={onInteract}
onBlur={onInteractOut}
style={[a.absolute, a.inset_0]}>
<></>
</Link>
{!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
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={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{view.source?.title}
</Text>
<StandardSiteMetaRow type="publication" view={view} />
</View>
)
}}
</Link>
</View>
{!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>
)
}
@@ -394,15 +379,24 @@ export function SubscribeButton({
if (!view.source) return null
const publicationTitle = view.source.title
const label = highlightedPublisher
? publicationTitle
? l`Subscribe to ${publicationTitle} on ${highlightedPublisher.name}`
: l`Subscribe on ${highlightedPublisher.name}`
: publicationTitle
? l`View ${publicationTitle}`
: l`View publication`
return (
<StandardSiteThemeProvider view={view}>
<Link
shouldProxy
to={view.source.uri}
label={cta}
label={label}
size="small"
color="secondary_inverted"
style={[style, a.gap_sm]}
style={[style, a.gap_sm, a.pointer_events_auto]}
onPress={onPress}
onLongPress={onLongPress}>
{highlightedPublisher ? (
@@ -424,15 +418,13 @@ export function SubscribeButton({
function PublicationIcon({
view,
size,
hovered,
themeColors,
}: {
view: AppBskyEmbedExternal.ViewExternal
size: number
hovered?: boolean
interacted?: boolean
themeColors: ThemeColors
}) {
const opacity = hovered ? 0.6 : 0.2
if (!view.source) return null
return view.source?.icon ? (
<View>
@@ -443,15 +435,7 @@ function PublicationIcon({
avatar={view.source.icon}
extraAviStyle={PUBLICATION_AVATAR_STYLE}
/>
<MediaInsetBorder
style={[
a.rounded_sm,
{
borderColor: themeColors.accentForeground,
opacity,
},
]}
/>
<MediaInsetBorder opaque style={[a.rounded_sm]} />
</View>
) : (
<View
@@ -470,15 +454,7 @@ function PublicationIcon({
style={[a.text_xl, a.font_bold, {color: themeColors.accentForeground}]}>
{[...view.source.title][0] ?? ''}
</Text>
<MediaInsetBorder
style={[
a.rounded_sm,
{
borderColor: themeColors.accentForeground,
opacity,
},
]}
/>
<MediaInsetBorder opaque style={[a.rounded_sm]} />
</View>
)
}
@@ -500,16 +476,10 @@ export function PublicationFooter({
const {t: l} = useLingui()
const {gtPhone} = useBreakpoints()
const {
state: maybeHovered,
onIn: onHoverIn,
onOut: onHoverOut,
state: interacted,
onIn: onInteract,
onOut: onInteractOut,
} = useInteractionState()
const {
state: interactedWithin,
onIn: onInteractWithin,
onOut: onInteractWithout,
} = useInteractionState()
const hovered = maybeHovered && !interactedWithin
if (!view.source) return null
@@ -523,27 +493,38 @@ export function PublicationFooter({
a.gap_md,
gtPhone && [a.flex_row, a.gap_sm],
]}
testID="publication-embed-footer"
// @ts-ignore it's Fine™
onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}>
testID="publication-embed-footer">
<Link
shouldProxy
to={view.source.uri}
label={l`View publication`}
label={
view.source.title ? l`View ${view.source.title}` : l`View publication`
}
onPress={onPress}
onLongPress={onLongPress}
style={[a.absolute, a.inset_0, web({outline: 0})]}
{...web({
onMouseEnter: onInteract,
onMouseLeave: onInteractOut,
})}
onFocus={onInteract}
onBlur={onInteractOut}>
<></>
</Link>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_sm,
gtPhone && a.flex_1,
a.pointer_events_none,
]}>
<PublicationIcon
view={view}
size={32}
hovered={hovered}
interacted={interacted}
themeColors={themeColors}
/>
<View style={[a.flex_1, a.gap_2xs]}>
@@ -553,23 +534,18 @@ export function PublicationFooter({
a.text_sm,
a.font_medium,
t.atoms.text,
hovered && a.underline,
interacted && a.underline,
]}>
{view.source?.title}
</Text>
<StandardSiteMetaRow
type="publication"
view={view}
onInteractWithin={onInteractWithin}
onInteractWithout={onInteractWithout}
/>
<StandardSiteMetaRow type="publication" view={view} />
</View>
</Link>
</View>
{!hideSubscribe && (
<SubscribeButton
view={view}
style={[!gtPhone && [a.w_full, a.justify_center]]}
style={[a.z_10, !gtPhone && [a.w_full, a.justify_center]]}
onPress={onPress}
onLongPress={onLongPress}
/>