This commit is contained in:
Eric Bailey
2026-05-21 13:22:17 -05:00
parent 1adfdb4a15
commit 21c9e5c38a
23 changed files with 787 additions and 434 deletions
@@ -1,108 +0,0 @@
import {View} from 'react-native'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
import {Leaf_Stroke2_Corner0_Rounded as Leaf} from '#/components/icons/Leaf'
import {Text} from '#/components/Typography'
import {type PublicationViewExternal} from './types'
export function MetaRow({link}: {link: PublicationViewExternal}) {
const t = useTheme()
const {i18n} = useLingui()
// Guard against malformed dates (REG/EDGE from sweep): only render when
// `new Date(...)` produces a finite timestamp.
let formattedDate: string | undefined
if (link.createdAt) {
const parsed = new Date(link.createdAt)
if (!Number.isNaN(parsed.getTime())) {
formattedDate = i18n.date(parsed, {dateStyle: 'medium'})
}
}
return (
<View
style={[
a.flex_row,
a.flex_wrap,
a.align_center,
a.justify_between,
a.gap_xs,
]}>
<View style={[a.flex_row, a.flex_wrap, a.align_center, a.gap_md]}>
{formattedDate && (
<Text style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_high]}>
{formattedDate}
</Text>
)}
{/*
TODO(APP-2160): Enable once the lexicon exposes an aggregate share
count for the external URL. `associatedBskyPost.repostCount` is the
wrong semantics (reshares of a single canonical post, not shares of
the URL). Component exists below for the moment that field lands.
*/}
{/* <SharesChip count={???} /> */}
{typeof link.readingTime === 'number' && link.readingTime > 0 && (
<ReadingTimeChip minutes={link.readingTime} />
)}
</View>
{/*
TODO(APP-2160): Enable once the lexicon exposes a host/platform name
on `viewExternalSource` (e.g. "Leaflet"). Component exists below for
the moment that field lands.
*/}
{/* <HostedByChip name={???} /> */}
</View>
)
}
function ReadingTimeChip({minutes}: {minutes: number}) {
const t = useTheme()
return (
<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]}>
<Trans comment="Reading time in minutes for an external article. # is the number.">
{plural(minutes, {one: '# min', other: '# min'})}
</Trans>
</Text>
</View>
)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function SharesChip({count}: {count: number}) {
const t = useTheme()
return (
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<Text
style={[
a.text_xs,
a.leading_snug,
a.underline,
t.atoms.text_contrast_high,
]}>
<Trans comment="Number of times an external URL has been shared on Bluesky. # is the count.">
{plural(count, {one: '# share', other: '# shares'})}
</Trans>
</Text>
</View>
)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function HostedByChip({name}: {name: string}) {
const t = useTheme()
return (
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<Leaf size="xs" style={[t.atoms.text_contrast_high]} />
<Text style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_high]}>
<Trans comment="Host platform label on an external article card. e.g. 'Hosted by Leaflet'.">
Hosted by {name}
</Trans>
</Text>
</View>
)
}
@@ -1,88 +0,0 @@
import {useEffect} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {useProfileQuery} from '#/state/queries/profile'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {type PublicationViewExternalSource} from './types'
import {parseDidFromAtUri} from './util'
export function PublicationFooter({
source,
}: {
source: PublicationViewExternalSource
}) {
const t = useTheme()
const {t: l} = useLingui()
const did = parseDidFromAtUri(source.associatedRecord?.uri)
const profileQuery = useProfileQuery({did: did ?? undefined})
const profileError = profileQuery.error
useEffect(() => {
if (profileError && did) {
// Log once per error transition. React Query handles transient network
// failures via retry; once the error is set we don't want to re-log on
// every render (could burn Sentry quota with a feed of broken DIDs).
logger.error('PublicationEmbed handle resolve failed', {
safeMessage: profileError,
})
}
}, [profileError, did])
const handle = did ? profileQuery.data?.handle : undefined
const name = source.name || (source.uri ? toNiceDomain(source.uri) : '')
const content = (hovered: boolean) => (
<View
style={[
a.flex_row,
a.align_center,
a.px_md,
a.py_md,
a.transition_color,
{gap: 10},
hovered ? t.atoms.bg_contrast_25 : null,
]}
testID="publication-embed-footer">
<UserAvatar type="user" size={40} avatar={source.icon} />
<View style={[a.flex_1, {minWidth: 0, gap: 2}]}>
<Text
numberOfLines={1}
style={[a.text_sm, a.font_medium, t.atoms.text]}>
{name}
</Text>
{handle && (
<Text
numberOfLines={1}
testID="publication-embed-handle"
style={[a.text_xs, t.atoms.text_contrast_medium]}>
<Trans comment="Authorship line on the publication card. {handle} is a bsky handle.">
by @{handle}
</Trans>
</Text>
)}
</View>
</View>
)
if (!source.uri) {
// No publication URL to link to; render the row as static content.
return content(false)
}
return (
<Link
to={source.uri}
label={
source.name ? l`View publication: ${source.name}` : l`View publication`
}
shouldProxy>
{({hovered}) => content(hovered)}
</Link>
)
}
@@ -1,32 +0,0 @@
import {parseDidFromAtUri} from '#/components/Post/Embed/ExternalEmbed/PublicationEmbed/util'
describe('parseDidFromAtUri', () => {
it('extracts a plc DID from a publication at-uri', () => {
expect(
parseDidFromAtUri('at://did:plc:abc123/site.standard.publication/3jx'),
).toBe('did:plc:abc123')
})
it('extracts a web DID', () => {
expect(
parseDidFromAtUri(
'at://did:web:example.com/site.standard.publication/3jx',
),
).toBe('did:web:example.com')
})
it('returns undefined for non-at-uri strings', () => {
expect(parseDidFromAtUri('https://example.com')).toBeUndefined()
})
it('returns undefined for empty / nullish input', () => {
expect(parseDidFromAtUri('')).toBeUndefined()
expect(parseDidFromAtUri(undefined)).toBeUndefined()
})
it('returns undefined when the authority is not a DID', () => {
expect(
parseDidFromAtUri('at://alice.bsky.social/site.standard.publication/3jx'),
).toBeUndefined()
})
})
@@ -1,116 +0,0 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {useLingui} from '@lingui/react/macro'
import {useHaptics} from '#/lib/haptics'
import {shareUrl} from '#/lib/sharing'
import {atoms as a, useTheme} from '#/alf'
import {Divider} from '#/components/Divider'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
import {MetaRow} from './MetaRow'
import {PublicationFooter} from './PublicationFooter'
import {
type PublicationViewExternal,
type PublicationViewExternalSource,
} from './types'
export function PublicationEmbed({
link,
source,
onOpen,
style,
}: {
link: PublicationViewExternal
source: PublicationViewExternalSource
onOpen?: () => void
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
const {t: l} = useLingui()
const playHaptic = useHaptics()
const onPress = () => {
playHaptic('Light')
onOpen?.()
}
const onShareExternal = () => {
if (link.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(link.uri)
}
}
return (
<View
testID="publication-embed"
style={[
a.w_full,
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
style,
]}>
<Link
to={link.uri}
label={link.title || l`Open link to ${link.uri}`}
shouldProxy
onPress={onPress}
onLongPress={onShareExternal}>
{({hovered}) => (
<View
style={[
a.flex_col,
a.w_full,
a.transition_color,
hovered ? t.atoms.bg_contrast_25 : null,
]}>
{link.thumb ? (
<Image
style={[a.aspect_card]}
source={{uri: link.thumb}}
accessibilityIgnoresInvertColors
loading="lazy"
/>
) : null}
<View style={[a.p_md, {gap: 6}]}>
<View style={[{gap: 4}]}>
<Text
emoji
numberOfLines={3}
style={[
a.text_md,
a.font_semi_bold,
a.leading_snug,
t.atoms.text,
]}>
{link.title || link.uri}
</Text>
{link.description ? (
<Text
emoji
numberOfLines={2}
style={[a.text_xs, a.leading_snug, t.atoms.text]}>
{link.description}
</Text>
) : null}
</View>
<MetaRow link={link} />
</View>
</View>
)}
</Link>
<Divider />
<PublicationFooter source={source} />
{/*
Note: `source.theme` (background/foreground/accent/accentForeground
RGB triplets) is exposed by the lexicon but not consumed here. The
current design does not apply per-publication accent colors. Pick up
when product asks for theme accenting.
*/}
</View>
)
}
@@ -1,42 +0,0 @@
import {
type AppBskyEmbedExternal,
type ComAtprotoRepoStrongRef,
} from '@atproto/api'
/**
* Local extension of `app.bsky.embed.external#viewExternal` that mirrors
* atproto PR #4915. Once @atproto/api ships those fields, delete this file
* and replace usages with `AppBskyEmbedExternal.ViewExternal` directly.
*/
export type PublicationViewExternal = AppBskyEmbedExternal.ViewExternal & {
createdAt?: string
updatedAt?: string
readingTime?: number
source?: PublicationViewExternalSource
associatedRecord?: ComAtprotoRepoStrongRef.Main
associatedBskyPost?: ComAtprotoRepoStrongRef.Main
}
export interface PublicationViewExternalSource {
$type?: 'app.bsky.embed.external#viewExternalSource'
uri?: string
icon?: string
name?: string
description?: string
theme?: PublicationViewExternalSourceTheme
associatedRecord?: ComAtprotoRepoStrongRef.Main
}
export interface PublicationViewExternalSourceTheme {
$type?: 'app.bsky.embed.external#viewExternalSourceTheme'
background?: PublicationColorRGB
foreground?: PublicationColorRGB
accent?: PublicationColorRGB
accentForeground?: PublicationColorRGB
}
export interface PublicationColorRGB {
r: number
g: number
b: number
}
@@ -1,10 +0,0 @@
/**
* Extract the DID from an at-uri of the form `at://did:<method>:<id>/<nsid>/<rkey>`.
* Returns undefined if the input is falsy, not an at-uri, or the authority is not a DID.
*/
export function parseDidFromAtUri(uri: string | undefined): string | undefined {
if (!uri || !uri.startsWith('at://')) return undefined
const authority = uri.slice('at://'.length).split('/')[0]
if (!authority || !authority.startsWith('did:')) return undefined
return authority
}