Standard Site cards tweaks (#10614)

This commit is contained in:
Eric Bailey
2026-05-27 11:53:26 -05:00
committed by GitHub
parent 721a08ffe1
commit 3d8875374e
14 changed files with 246 additions and 109 deletions
@@ -1,30 +1,33 @@
import {Fragment, type ReactNode} from 'react'
import {View} from 'react-native'
import {type AppBskyEmbedExternal, AtUri} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {makeProfileLink} from '#/lib/routes/links'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf'
import {StandardSite} from '#/components/icons/community/StandardSite'
import {InlineLinkText} from '#/components/Link'
import {
matchStandardSitePublisher,
matchStandardSitePublisherByUri,
} from '#/components/Post/Embed/StandardSiteEmbed/publishers'
import type * as ssTypes from '#/components/Post/Embed/StandardSiteEmbed/types'
import {
isStandardSiteDocumentUri,
isStandardSitePublicationUri,
} from '#/components/Post/Embed/StandardSiteEmbed/utils'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
export function StandardSiteMetaRow({
type = 'document',
preview,
view,
}: {
type?: 'document' | 'publication'
view: AppBskyEmbedExternal.ViewExternal
}) {
}: ssTypes.CommonProps &
ssTypes.PreviewProps & {
type?: 'document' | 'publication'
}) {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui()
const highlightedPublisher = !!matchStandardSitePublisher(view)
@@ -43,7 +46,7 @@ export function StandardSiteMetaRow({
: undefined
const articleDomain = toNiceDomain(view.uri)
const articlePublisher = matchStandardSitePublisherByUri(view.uri)
const DomainIcon = articlePublisher?.Icon ?? StandardSite
const DomainIcon = articlePublisher?.Icon
const metaTextStyle = [
a.text_xs,
a.leading_snug,
@@ -57,7 +60,9 @@ export function StandardSiteMetaRow({
key: 'domain',
node: (
<View style={[a.flex_row, a.align_center]}>
<DomainIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
{DomainIcon && (
<DomainIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
)}
<Text numberOfLines={1} style={metaTextStyle}>
{articleDomain}
</Text>
@@ -76,7 +81,15 @@ export function StandardSiteMetaRow({
<InlineLinkText
label={l`View @${authorProfile.handle}'s profile`}
to={makeProfileLink(authorProfile)}
style={[metaTextStyle, a.pointer_events_auto]}>
style={[
metaTextStyle,
preview ? a.pointer_events_none : a.pointer_events_auto,
]}
onPress={() => {
ax.metric('embed:standardSite:authorHandle:press', {
handle: authorProfile.handle,
})
}}>
@{authorProfile.handle}
</InlineLinkText>
</Trans>
@@ -1,10 +1,11 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {type AppBskyEmbedExternal, AtUri} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {useHaptics} from '#/lib/haptics'
import {useCallOnce} from '#/lib/once'
import {shareUrl} from '#/lib/sharing'
import {niceDate} from '#/lib/strings/time'
import {toNiceDomain} from '#/lib/strings/url-helpers'
@@ -13,37 +14,34 @@ 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'
export type ThemeColors = {
custom: boolean
accent: string
accentForeground: string
}
const PUBLICATION_AVATAR_STYLE = {
borderRadius: a.rounded_sm.borderRadius,
}
export const StandardSiteEmbed = ({
preview,
view,
onOpen,
onEmbedInteractionCallback,
style,
hideSubscribe,
}: {
view: AppBskyEmbedExternal.ViewExternal
onOpen?: () => void
style?: StyleProp<ViewStyle>
hideSubscribe?: boolean
}) => {
}: ssTypes.CommonProps &
ssTypes.PreviewProps &
ssTypes.PublicApiProps & {
style?: StyleProp<ViewStyle>
}) => {
const ax = useAnalytics()
const {t: l, i18n} = useLingui()
const t = useTheme()
const playHaptic = useHaptics()
@@ -54,7 +52,7 @@ export const StandardSiteEmbed = ({
new AtUri(ref.uri).collection.startsWith('site.standard.'),
)
const isStandardPublication = isStandardSitePublicationEmbed(view)
let themeColors: ThemeColors = {
let themeColors: ssTypes.ThemeColors = {
custom: false,
accent: t.atoms.text.color,
accentForeground: t.atoms.text_inverted.color,
@@ -77,28 +75,51 @@ export const StandardSiteEmbed = ({
onIn: onInteract,
onOut: onInteractOut,
} = useInteractionState()
const onPress = () => {
playHaptic('Light')
onOpen?.()
onEmbedInteractionCallback?.()
ax.metric('embed:standardSite:article:press', {url: view.uri})
}
const onLongPress = () => {
if (view.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.uri)
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
}
}
const onPressPublication = () => {
playHaptic('Light')
onEmbedInteractionCallback?.()
ax.metric('embed:standardSite:publication:press', {
url: view.source?.uri || '',
})
}
const onLongPressPublication = () => {
if (view.source?.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.source.uri)
ax.metric('embed:standardSite:publication:longPress', {
url: view.source.uri,
})
}
}
useCallOnce(() => {
if (!preview) {
ax.metric('embed:standardSite:view', {url: view.uri})
}
})()
if (isStandardPublication) {
return (
<PublicationCard
hideSubscribe={hideSubscribe}
preview={preview}
view={view}
onPress={onPress}
onLongPress={onLongPress}
onPress={onPressPublication}
onLongPress={onLongPressPublication}
style={style}
themeColors={themeColors}
onEmbedInteractionCallback={onEmbedInteractionCallback}
/>
)
}
@@ -111,7 +132,9 @@ export const StandardSiteEmbed = ({
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
@@ -130,7 +153,13 @@ export const StandardSiteEmbed = ({
<></>
</Link>
<View style={[a.w_full, a.z_10, a.pointer_events_none]}>
<View
style={[
a.w_full,
a.z_10,
a.pointer_events_none,
interacted && [t.atoms.bg_contrast_25],
]}>
{imageUri ? (
<Image
style={[a.aspect_card]}
@@ -214,45 +243,51 @@ export const StandardSiteEmbed = ({
)}
</View>
</View>
</View>
<View style={[a.z_20]}>
<View style={[a.px_md]}>
<Divider />
</View>
{view.source ? (
<PublicationFooter
view={view}
onPress={onPress}
onLongPress={onLongPress}
themeColors={themeColors}
hideSubscribe={hideSubscribe}
/>
) : (
<View style={[a.px_md, a.py_sm, a.pointer_events_none]}>
<StandardSiteMetaRow 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,
hideSubscribe,
onPress,
onLongPress,
themeColors,
style,
}: {
view: AppBskyEmbedExternal.ViewExternal
hideSubscribe?: boolean
onPress?: () => void
onLongPress?: () => void
themeColors: ThemeColors
style?: StyleProp<ViewStyle>
}) {
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()
@@ -272,7 +307,9 @@ export function PublicationCard({
a.w_full,
a.border,
a.p_md,
interacted ? t.atoms.border_contrast_high : t.atoms.border_contrast_low,
interacted
? [t.atoms.bg_contrast_25, t.atoms.border_contrast_high]
: [t.atoms.bg, t.atoms.border_contrast_low],
style,
]}>
<Link
@@ -322,16 +359,20 @@ export function PublicationCard({
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{view.source?.title}
</Text>
<StandardSiteMetaRow type="publication" view={view} />
<StandardSiteMetaRow
preview={preview}
type="publication"
view={view}
/>
</View>
</View>
{!hideSubscribe && gtPhone && (
{gtPhone && (
<SubscribeButton
preview={preview}
view={view}
style={[!gtPhone && [a.w_full, a.justify_center]]}
onPress={onPress}
onLongPress={onLongPress}
onEmbedInteractionCallback={onEmbedInteractionCallback}
/>
)}
</View>
@@ -345,13 +386,13 @@ export function PublicationCard({
</View>
)}
{!hideSubscribe && !gtPhone && (
{!gtPhone && (
<View style={[view.description && a.pt_sm]}>
<SubscribeButton
preview={preview}
view={view}
style={[!gtPhone && [a.w_full, a.justify_center]]}
onPress={onPress}
onLongPress={onLongPress}
onEmbedInteractionCallback={onEmbedInteractionCallback}
/>
</View>
)}
@@ -361,17 +402,19 @@ export function PublicationCard({
}
export function SubscribeButton({
preview,
view,
onPress,
onLongPress,
style,
}: {
view: AppBskyEmbedExternal.ViewExternal
onPress?: () => void
onLongPress?: () => void
style?: StyleProp<ViewStyle>
}) {
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}`
@@ -388,6 +431,36 @@ export function SubscribeButton({
? 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 = () => {
if (view.source?.uri && IS_NATIVE) {
playHaptic('Heavy')
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 || '',
})
}
}
}
return (
<StandardSiteThemeProvider view={view}>
<Link
@@ -396,19 +469,25 @@ export function SubscribeButton({
label={label}
size="small"
color="secondary_inverted"
style={[style, a.gap_sm, a.pointer_events_auto]}
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="lg" />
{/*<ButtonText>|</ButtonText>*/}
<ButtonIcon icon={highlightedPublisher.Icon} size="md" />
</View>
<ButtonText>{cta}</ButtonText>
</>
) : (
<ButtonText>{cta}</ButtonText>
<>
<ButtonText>{cta}</ButtonText>
<ButtonIcon icon={ArrowTopRightIcon} />
</>
)}
</Link>
</StandardSiteThemeProvider>
@@ -419,11 +498,10 @@ function PublicationIcon({
view,
size,
themeColors,
}: {
view: AppBskyEmbedExternal.ViewExternal
}: ssTypes.CommonProps & {
size: number
interacted?: boolean
themeColors: ThemeColors
themeColors: ssTypes.ThemeColors
}) {
if (!view.source) return null
return view.source?.icon ? (
@@ -460,18 +538,21 @@ function PublicationIcon({
}
export function PublicationFooter({
preview,
view,
hideSubscribe,
themeColors,
onPress,
onLongPress,
themeColors,
}: {
view: AppBskyEmbedExternal.ViewExternal
hideSubscribe?: boolean
themeColors: ThemeColors
onPress?: () => void
onLongPress?: () => void
}) {
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()
@@ -492,8 +573,9 @@ export function PublicationFooter({
a.p_md,
a.gap_md,
gtPhone && [a.flex_row, a.gap_sm],
]}
testID="publication-embed-footer">
interactedOuter && t.atoms.bg_contrast_25,
preview && a.pointer_events_none,
]}>
<Link
shouldProxy
to={view.source.uri}
@@ -538,18 +620,20 @@ export function PublicationFooter({
]}>
{view.source?.title}
</Text>
<StandardSiteMetaRow type="publication" view={view} />
<StandardSiteMetaRow
preview={preview}
type="publication"
view={view}
/>
</View>
</View>
{!hideSubscribe && (
<SubscribeButton
view={view}
style={[a.z_10, !gtPhone && [a.w_full, a.justify_center]]}
onPress={onPress}
onLongPress={onLongPress}
/>
)}
<SubscribeButton
preview={preview}
view={view}
style={[a.z_10, !gtPhone && [a.w_full, a.justify_center]]}
onEmbedInteractionCallback={onEmbedInteractionCallback}
/>
</View>
)
}
@@ -0,0 +1,26 @@
import {type AppBskyEmbedExternal} from '@atproto/api'
export type CommonProps = {
view: AppBskyEmbedExternal.ViewExternal
}
export type PreviewProps = {
/**
* Indicates the card is showing the composer. Interactive elements should be
* disabled when this value is true.
*/
preview?: boolean
}
export type PublicApiProps = {
/**
* Passed down from feed contexts etc, used to fire off interaction tracking
*/
onEmbedInteractionCallback?: () => void
}
export type ThemeColors = {
custom: boolean
accent: string
accentForeground: string
}
+1
View File
@@ -104,6 +104,7 @@ function MediaEmbed({
activeStyle={[a.mt_sm]}>
<StandardSiteEmbed
view={embed.view.external}
onEmbedInteractionCallback={rest.onOpen}
style={[a.mt_sm, rest.style]}
/>
</ContentHider>