Files
bsky-social-app/src/components/Post/Embed/StandardSiteEmbed/index.tsx
T
2026-06-05 16:04:21 -05:00

674 lines
18 KiB
TypeScript

import {type StyleProp, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {AtUri} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {useHaptics} from '#/lib/haptics'
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, web} from '#/alf'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRightIcon} from '#/components/icons/Arrow'
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
import {Link} from '#/components/Link'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {matchStandardSitePublisher} from '#/components/Post/Embed/StandardSiteEmbed/publishers'
import {StandardSiteMetaRow} from '#/components/Post/Embed/StandardSiteEmbed/StandardSiteMetaRow'
import {StandardSiteThemeProvider} from '#/components/Post/Embed/StandardSiteEmbed/StandardSiteThemeProvider'
import type * as ssTypes from '#/components/Post/Embed/StandardSiteEmbed/types'
import {isStandardSitePublicationEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
const PUBLICATION_AVATAR_STYLE = {
borderRadius: a.rounded_sm.borderRadius,
}
export const StandardSiteEmbed = ({
preview,
view,
onEmbedInteractionCallback,
style,
}: ssTypes.CommonProps &
ssTypes.PreviewProps &
ssTypes.PublicApiProps & {
style?: StyleProp<ViewStyle>
}) => {
const ax = useAnalytics()
const {t: l, i18n} = useLingui()
const t = useTheme()
const playHaptic = useHaptics()
const niceUrl = toNiceDomain(view.uri)
const imageUri = view.thumb
const hasMedia = Boolean(imageUri)
const isStandard = view.associatedRefs?.some(ref =>
new AtUri(ref.uri).collection.startsWith('site.standard.'),
)
const isStandardPublication = isStandardSitePublicationEmbed(view)
let themeColors: ssTypes.ThemeColors = {
custom: false,
accent: t.atoms.text.color,
accentForeground: t.atoms.text_inverted.color,
}
const {accentRGB, accentForegroundRGB} = view.source?.theme || {}
if (accentRGB && accentForegroundRGB) {
themeColors = {
custom: true,
accent: utils.rgbToHex(accentRGB.r, accentRGB.g, accentRGB.b),
accentForeground: utils.rgbToHex(
accentForegroundRGB.r,
accentForegroundRGB.g,
accentForegroundRGB.b,
),
}
}
const {
state: interacted,
onIn: onInteract,
onOut: onInteractOut,
} = useInteractionState()
const onPress = () => {
playHaptic('Light')
onEmbedInteractionCallback?.()
ax.metric('embed:standardSite:article:press', {url: view.uri})
}
const onLongPress = IS_NATIVE
? () => {
if (view.uri) {
playHaptic('Heavy')
void shareUrl(view.uri)
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
}
}
: undefined
const onPressPublication = () => {
playHaptic('Light')
onEmbedInteractionCallback?.()
ax.metric('embed:standardSite:publication:press', {
url: view.source?.uri || '',
})
}
const onLongPressPublication = IS_NATIVE
? () => {
if (view.source?.uri) {
playHaptic('Heavy')
void shareUrl(view.source.uri)
ax.metric('embed:standardSite:publication:longPress', {
url: view.source.uri,
})
}
}
: undefined
if (isStandardPublication) {
return (
<PublicationCard
preview={preview}
view={view}
onPress={onPressPublication}
onLongPress={onLongPressPublication}
style={style}
themeColors={themeColors}
onEmbedInteractionCallback={onEmbedInteractionCallback}
/>
)
}
return (
<View
style={[
a.flex_col,
a.rounded_lg,
a.overflow_hidden,
a.w_full,
a.border,
t.atoms.bg,
interacted ? t.atoms.border_contrast_high : t.atoms.border_contrast_low,
preview && a.pointer_events_none,
style,
]}>
<Link
shouldProxy
to={view.uri}
label={view.title || l`Open link to ${niceUrl}`}
onPress={onPress}
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,
interacted && [t.atoms.bg_contrast_25],
]}>
{imageUri ? (
<Image
style={[a.aspect_card]}
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
loading="lazy"
useAppleWebpCodec
/>
) : 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_row, a.align_center, a.gap_md, {paddingTop: 2}]}>
{view.createdAt && (
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{niceDate(i18n, view.createdAt, 'long', 'none')}
</Text>
)}
{view.readingTime && (
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<Clock size="xs" style={t.atoms.text_contrast_medium} />
<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>
{!view.source && (
<View style={[a.px_md]}>
<Divider />
<View style={[a.py_sm]}>
<StandardSiteMetaRow preview={preview} view={view} />
</View>
</View>
)}
</View>
{view.source && (
<View style={[a.z_20]}>
<Divider />
<PublicationFooter
preview={preview}
view={view}
onPress={onPressPublication}
onLongPress={onLongPressPublication}
themeColors={themeColors}
interactedOuter={interacted}
onEmbedInteractionCallback={onEmbedInteractionCallback}
/>
</View>
)}
</View>
)
}
export function PublicationCard({
preview,
view,
onPress,
onLongPress,
themeColors,
style,
onEmbedInteractionCallback,
}: ssTypes.CommonProps &
ssTypes.PreviewProps &
ssTypes.PublicApiProps & {
onPress?: () => void
onLongPress?: () => void
themeColors: ssTypes.ThemeColors
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
const {t: l} = useLingui()
const {gtPhone} = useBreakpoints()
const {
state: interacted,
onIn: onInteract,
onOut: onInteractOut,
} = useInteractionState()
if (!view.source) return null
return (
<View
style={[
a.rounded_lg,
a.overflow_hidden,
a.w_full,
a.border,
a.p_md,
interacted
? [t.atoms.bg_contrast_25, t.atoms.border_contrast_high]
: [t.atoms.bg, 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>
<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
emoji
numberOfLines={1}
style={[
a.text_md,
a.font_semi_bold,
t.atoms.text,
a.leading_snug,
]}>
{view.source?.title}
</Text>
<StandardSiteMetaRow
preview={preview}
type="publication"
view={view}
/>
</View>
</View>
{gtPhone && (
<SubscribeButton
preview={preview}
view={view}
style={[!gtPhone && [a.w_full, a.justify_center]]}
onEmbedInteractionCallback={onEmbedInteractionCallback}
/>
)}
</View>
<View style={[a.pointer_events_none]}>
{view.description && (
<View style={[a.pt_sm]}>
<Text emoji style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
{view.description}
</Text>
</View>
)}
{!gtPhone && (
<View style={[a.pt_sm]}>
<SubscribeButton
preview={preview}
view={view}
style={[!gtPhone && [a.w_full, a.justify_center]]}
onEmbedInteractionCallback={onEmbedInteractionCallback}
/>
</View>
)}
</View>
</View>
)
}
export function SubscribeButton({
preview,
view,
style,
onEmbedInteractionCallback,
}: ssTypes.CommonProps &
ssTypes.PreviewProps &
ssTypes.PublicApiProps & {
style?: StyleProp<ViewStyle>
}) {
const ax = useAnalytics()
const {t: l} = useLingui()
const playHaptic = useHaptics()
const highlightedPublisher = matchStandardSitePublisher(view)
const cta = highlightedPublisher
? l`Subscribe on ${highlightedPublisher.name}`
: l`View publication`
/*
* The custom site theme paints the button background with `accent` and the
* text with `accentForeground`. Only honor it when that pairing clears WCAG
* AAA (4.5:1) for large text, which the button's bold label qualifies as.
* Otherwise we fall through to the default `secondary_inverted` styling,
* which is guaranteed to be legible.
*/
const {accentRGB, accentForegroundRGB} = view.source?.theme || {}
let useCustomTheme = false
if (accentRGB && accentForegroundRGB) {
const accent = utils.rgbToHex(accentRGB.r, accentRGB.g, accentRGB.b)
const accentForeground = utils.rgbToHex(
accentForegroundRGB.r,
accentForegroundRGB.g,
accentForegroundRGB.b,
)
const ratio = utils.contrastRatio(accent, accentForeground)
useCustomTheme = ratio !== null && ratio >= 4.5
}
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`
const onPress = () => {
playHaptic('Light')
onEmbedInteractionCallback?.()
if (highlightedPublisher) {
ax.metric('embed:standardSite:subscribe:press', {
url: view.source?.uri || '',
})
} else {
ax.metric('embed:standardSite:publicationCta:press', {
url: view.source?.uri || '',
})
}
}
const onLongPress = IS_NATIVE
? () => {
if (view.source?.uri) {
playHaptic('Heavy')
void shareUrl(view.source.uri)
if (highlightedPublisher) {
ax.metric('embed:standardSite:subscribe:longPress', {
url: view.source?.uri || '',
})
} else {
ax.metric('embed:standardSite:publicationCta:longPress', {
url: view.source?.uri || '',
})
}
}
}
: undefined
const button = (
<Link
shouldProxy
to={view.source.uri}
label={label}
size="small"
color="secondary_inverted"
style={[
style,
a.gap_sm,
preview ? a.pointer_events_none : a.pointer_events_auto,
]}
onPress={onPress}
onLongPress={onLongPress}>
{highlightedPublisher ? (
<>
<View style={[a.flex_row, a.align_center, {gap: 7}]}>
<ButtonIcon icon={highlightedPublisher.Icon} size="md" />
</View>
<ButtonText>{cta}</ButtonText>
</>
) : (
<>
<ButtonText>{cta}</ButtonText>
<ButtonIcon icon={ArrowTopRightIcon} />
</>
)}
</Link>
)
if (!useCustomTheme) {
return button
}
return (
<StandardSiteThemeProvider view={view}>{button}</StandardSiteThemeProvider>
)
}
function PublicationIcon({
view,
size,
themeColors,
}: ssTypes.CommonProps & {
size: number
interacted?: boolean
themeColors: ssTypes.ThemeColors
}) {
if (!view.source) return null
return view.source?.icon ? (
<View>
<UserAvatar
noBorder
type="labeler"
size={size}
avatar={view.source.icon}
extraAviStyle={PUBLICATION_AVATAR_STYLE}
/>
<MediaInsetBorder opaque style={[a.rounded_sm]} />
</View>
) : (
<View
style={[
a.align_center,
a.justify_center,
a.rounded_sm,
{
width: size,
height: size,
backgroundColor: themeColors.accent,
},
]}>
<Text
emoji
style={[a.text_xl, a.font_bold, {color: themeColors.accentForeground}]}>
{[...view.source.title][0] ?? ''}
</Text>
<MediaInsetBorder opaque style={[a.rounded_sm]} />
</View>
)
}
export function PublicationFooter({
preview,
view,
themeColors,
onPress,
onLongPress,
interactedOuter,
onEmbedInteractionCallback,
}: ssTypes.CommonProps &
ssTypes.PreviewProps &
ssTypes.PublicApiProps & {
themeColors: ssTypes.ThemeColors
onPress?: () => void
onLongPress?: () => void
interactedOuter?: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
const {gtPhone} = useBreakpoints()
const {
state: interacted,
onIn: onInteract,
onOut: onInteractOut,
} = useInteractionState()
if (!view.source) return null
return (
<View
style={[
a.flex_1,
a.align_center,
a.justify_between,
a.p_md,
a.gap_md,
gtPhone && [a.flex_row, a.gap_sm],
interactedOuter && t.atoms.bg_contrast_25,
preview && a.pointer_events_none,
]}>
<Link
shouldProxy
to={view.source.uri}
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}
interacted={interacted}
themeColors={themeColors}
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
emoji
numberOfLines={1}
style={[
a.text_sm,
a.font_medium,
a.leading_tight,
t.atoms.text,
interacted && a.underline,
]}>
{view.source?.title}
</Text>
<StandardSiteMetaRow
preview={preview}
type="publication"
view={view}
/>
</View>
</View>
<SubscribeButton
preview={preview}
view={view}
style={[a.z_10, !gtPhone && [a.w_full, a.justify_center]]}
onEmbedInteractionCallback={onEmbedInteractionCallback}
/>
</View>
)
}