From f5dcce171843b88e090230d4aa7109019f80b8d1 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 25 May 2026 15:49:30 -0500 Subject: [PATCH] Standard Site link card improvements (#10608) --- src/components/Link.tsx | 78 ++- .../StandardSiteEmbed/StandardSiteMetaRow.tsx | 14 +- .../Post/Embed/StandardSiteEmbed/index.tsx | 484 +++++++++--------- 3 files changed, 290 insertions(+), 286 deletions(-) diff --git a/src/components/Link.tsx b/src/components/Link.tsx index d667823a5d..fcb2e8a87f 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -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 & - Pick & { + Pick & { 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) => { + rest.onFocus?.(e) + onInteract() }} - onMouseLeave={() => { - rest.onMouseLeave?.() - onHoverOut() + onBlur={(e: NativeSyntheticEvent) => { + 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) => { + rest.onFocus?.(e) + onInteract() + }} + onBlur={(e: NativeSyntheticEvent) => { + rest.onBlur?.(e) + onInteractOut() + }} accessibilityRole="link" href={href} {...web({ diff --git a/src/components/Post/Embed/StandardSiteEmbed/StandardSiteMetaRow.tsx b/src/components/Post/Embed/StandardSiteEmbed/StandardSiteMetaRow.tsx index 4b983f4d6e..bf5fe3d1a9 100644 --- a/src/components/Post/Embed/StandardSiteEmbed/StandardSiteMetaRow.tsx +++ b/src/components/Post/Embed/StandardSiteEmbed/StandardSiteMetaRow.tsx @@ -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({ { - // this link is nested, yes it's not ideal - e.stopPropagation() - }} - onMouseEnter={onInteractWithin} - onMouseLeave={onInteractWithout}> + style={[metaTextStyle, a.pointer_events_auto]}> @{authorProfile.handle} @@ -98,7 +88,7 @@ export function StandardSiteMetaRow({ if (items.length === 0) return null return ( - + {items.map((item, i) => ( {i > 0 && } diff --git a/src/components/Post/Embed/StandardSiteEmbed/index.tsx b/src/components/Post/Embed/StandardSiteEmbed/index.tsx index e618fa35e2..5af033fe71 100644 --- a/src/components/Post/Embed/StandardSiteEmbed/index.tsx +++ b/src/components/Post/Embed/StandardSiteEmbed/index.tsx @@ -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, ]}> - {({hovered: maybeHovered}) => { - const hovered = maybeHovered && !interactedWithin - return ( - - {imageUri ? ( - - ) : undefined} + onLongPress={onLongPress} + style={[a.absolute, a.inset_0, a.z_10]} + {...web({ + onMouseEnter: onInteract, + onMouseLeave: onInteractOut, + })} + onFocus={onInteract} + onBlur={onInteractOut}> + <> + + + {imageUri ? ( + + ) : undefined} + + + + + {view.title} + + {view.description ? ( + + {view.description} + + ) : undefined} + + {isStandard && (view.createdAt || view.readingTime) && ( - + style={[a.flex_row, a.align_center, a.gap_md, {paddingTop: 2}]}> + {view.createdAt && ( - {view.title} + {niceDate(i18n, view.createdAt, 'long', 'none')} - {view.description ? ( + )} + {view.readingTime && ( + + - {view.description} - - ) : undefined} - - {isStandard && (view.createdAt || view.readingTime) && ( - - {view.createdAt && ( - - {niceDate(i18n, view.createdAt, 'medium', 'none')} - - )} - {view.readingTime && ( - - - - {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.`, - })} - - - )} - - )} - - - {!view.source && ( - - - - - + {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.`, + })} + )} - - ) - }} - - - {view.source && ( - <> - - + )} + + + + + + + + {view.source ? ( - - )} + ) : ( + + + + )} + ) } @@ -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 ( - - {({hovered: maybeHovered}) => { - const hovered = maybeHovered && !interactedWithin - return ( - - - - - - - {view.source?.title} - - - - + + + <> + - {!hideSubscribe && gtPhone && ( - - )} - - - {view.description && ( - - - {view.description} - - - )} - - {!hideSubscribe && !gtPhone && ( - - - - )} + + + + + + {view.source?.title} + + - ) - }} - + + + {!hideSubscribe && gtPhone && ( + + )} + + + + {view.description && ( + + + {view.description} + + + )} + + {!hideSubscribe && !gtPhone && ( + + + + )} + + ) } @@ -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 ( {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 ? ( @@ -443,15 +435,7 @@ function PublicationIcon({ avatar={view.source.icon} extraAviStyle={PUBLICATION_AVATAR_STYLE} /> - + ) : ( {[...view.source.title][0] ?? ''} - + ) } @@ -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"> + <> + + + @@ -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} - + - + {!hideSubscribe && (