UI work
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyEmbedExternal, 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 {
|
||||
exemptExternalEmbedSources,
|
||||
parseEmbedPlayerFromUrl,
|
||||
} from '#/lib/strings/embed-player'
|
||||
import {niceDate} from '#/lib/strings/time'
|
||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||
import {useExternalEmbedsPrefs} from '#/state/preferences'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
|
||||
import {Leaflet} from '#/components/icons/community/Leaflet'
|
||||
import {Offprint} from '#/components/icons/community/Offprint'
|
||||
import {Pckt} from '#/components/icons/community/Pckt'
|
||||
import {StandardSite} from '#/components/icons/community/StandardSite'
|
||||
import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||
import {Link} from '#/components/Link'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
|
||||
export type ThemeColors = {
|
||||
accent: string
|
||||
accentForeground: string
|
||||
}
|
||||
|
||||
export function isStandardSiteEmbed(view: AppBskyEmbedExternal.ViewExternal) {
|
||||
return view.associatedRefs?.some(ref =>
|
||||
new AtUri(ref.uri).collection.startsWith('site.standard.'),
|
||||
)
|
||||
}
|
||||
|
||||
export function useStandardSitePublisherConfig(
|
||||
view: AppBskyEmbedExternal.ViewExternal,
|
||||
) {
|
||||
return useMemo(() => {
|
||||
try {
|
||||
const u = new URL(view.source?.uri || '')
|
||||
if (u.host.endsWith('leaflet.pub')) {
|
||||
return {
|
||||
name: 'Leaflet',
|
||||
Icon: Leaflet,
|
||||
}
|
||||
} else if (u.host.endsWith('pckt.blog')) {
|
||||
return {
|
||||
name: 'pckt',
|
||||
Icon: Pckt,
|
||||
}
|
||||
} else if (u.host.endsWith('offprint.app')) {
|
||||
return {
|
||||
name: 'Offprint',
|
||||
Icon: Offprint,
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}, [view])
|
||||
}
|
||||
|
||||
export const StandardSiteEmbed = ({
|
||||
view,
|
||||
onOpen,
|
||||
style,
|
||||
hideSubscribe,
|
||||
}: {
|
||||
view: AppBskyEmbedExternal.ViewExternal
|
||||
onOpen?: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
hideSubscribe?: boolean
|
||||
}) => {
|
||||
const {t: l, i18n} = useLingui()
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
const externalEmbedPrefs = useExternalEmbedsPrefs()
|
||||
const niceUrl = toNiceDomain(view.uri)
|
||||
const imageUri = view.thumb
|
||||
const embedPlayerParams = useMemo(() => {
|
||||
const params = parseEmbedPlayerFromUrl(view.uri)
|
||||
if (!params) return
|
||||
const canShow = externalEmbedPrefs?.[params.source] !== 'hide'
|
||||
if (canShow || exemptExternalEmbedSources.has(params.source)) {
|
||||
return params
|
||||
}
|
||||
}, [view.uri, externalEmbedPrefs])
|
||||
const hasMedia = Boolean(imageUri || embedPlayerParams)
|
||||
const isStandard = view.associatedRefs?.some(ref =>
|
||||
new AtUri(ref.uri).collection.startsWith('site.standard.'),
|
||||
)
|
||||
const isStandardPublication = view.associatedRefs?.every(
|
||||
ref =>
|
||||
new AtUri(ref.uri).collection === 'site.standard.publication' &&
|
||||
new AtUri(ref.uri).collection !== 'site.standard.document',
|
||||
)
|
||||
const themeColors = useMemo(() => {
|
||||
let accent = t.atoms.text.color
|
||||
let accentForeground = t.atoms.text_inverted.color
|
||||
const {accentRGB, accentForegroundRGB} = view.source?.theme || {}
|
||||
if (accent && accentForeground) {
|
||||
accent = colorRGBToHex(accentRGB)
|
||||
accentForeground = colorRGBToHex(accentForegroundRGB)
|
||||
}
|
||||
return {accent, accentForeground}
|
||||
}, [view])
|
||||
|
||||
const maybeAuthorDid = useMemo(() => {
|
||||
const publicationUri = view.associatedRefs?.find(
|
||||
ref => new AtUri(ref.uri).collection === 'site.standard.publication',
|
||||
)?.uri
|
||||
if (!publicationUri) return null
|
||||
return new AtUri(publicationUri)?.did
|
||||
}, [view])
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
playHaptic('Light')
|
||||
onOpen?.()
|
||||
}, [playHaptic, onOpen])
|
||||
|
||||
const onLongPress = useCallback(() => {
|
||||
if (view.uri && IS_NATIVE) {
|
||||
playHaptic('Heavy')
|
||||
shareUrl(view.uri)
|
||||
}
|
||||
}, [view.uri, playHaptic])
|
||||
|
||||
if (isStandardPublication) {
|
||||
return (
|
||||
<PublicationCard
|
||||
author={{did: maybeAuthorDid}}
|
||||
hideSubscribe={hideSubscribe}
|
||||
view={view}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
style={style}
|
||||
themeColors={themeColors}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.w_full,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
style,
|
||||
]}>
|
||||
<Link
|
||||
shouldProxy
|
||||
to={view.uri}
|
||||
label={view.title || l`Open link to ${niceUrl}`}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}>
|
||||
{({hovered}) => (
|
||||
<View style={[a.w_full]}>
|
||||
{imageUri ? (
|
||||
<Image
|
||||
style={[a.aspect_card]}
|
||||
source={{uri: imageUri}}
|
||||
accessibilityIgnoresInvertColors
|
||||
loading="lazy"
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
<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],
|
||||
]}>
|
||||
<Text
|
||||
emoji
|
||||
numberOfLines={3}
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
a.leading_snug,
|
||||
isStandard && [
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
hovered && a.underline,
|
||||
],
|
||||
]}>
|
||||
{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_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.flex_row,
|
||||
a.align_center,
|
||||
a.gap_2xs,
|
||||
a.pb_sm,
|
||||
{
|
||||
paddingTop: 6, // off menu
|
||||
},
|
||||
]}>
|
||||
<Globe
|
||||
size="xs"
|
||||
style={[
|
||||
a.transition_color,
|
||||
hovered
|
||||
? t.atoms.text_contrast_medium
|
||||
: t.atoms.text_contrast_low,
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.transition_color,
|
||||
a.text_xs,
|
||||
a.leading_snug,
|
||||
hovered
|
||||
? t.atoms.text_contrast_high
|
||||
: t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{toNiceDomain(view.uri)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{view.source && (
|
||||
<View style={[a.px_md]}>
|
||||
<Divider />
|
||||
<PublicationFooter
|
||||
view={view}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
themeColors={themeColors}
|
||||
author={{did: maybeAuthorDid}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function PublicationCard({
|
||||
view,
|
||||
hideSubscribe,
|
||||
onPress,
|
||||
onLongPress,
|
||||
themeColors,
|
||||
style,
|
||||
author,
|
||||
}: {
|
||||
view: AppBskyEmbedExternal.ViewExternal
|
||||
hideSubscribe?: boolean
|
||||
onPress?: () => void
|
||||
onLongPress?: () => void
|
||||
themeColors: ThemeColors
|
||||
style?: StyleProp<ViewStyle>
|
||||
author: {did: string | null | undefined}
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const profileQuery = useProfileQuery({did: author.did ?? undefined})
|
||||
const handle = author.did ? profileQuery.data?.handle : undefined
|
||||
const highlightedPublisher = useStandardSitePublisherConfig(view)
|
||||
|
||||
if (!view.source) return null
|
||||
|
||||
return (
|
||||
<Link
|
||||
shouldProxy
|
||||
to={view.source.uri}
|
||||
label={l`Subscribe`}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}>
|
||||
{({hovered}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.rounded_md,
|
||||
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>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
{[
|
||||
{
|
||||
type: 'domain',
|
||||
enabled: !highlightedPublisher,
|
||||
value: toNiceDomain(view.source?.uri || ''),
|
||||
hasPrev: false,
|
||||
},
|
||||
{
|
||||
type: 'author',
|
||||
enabled: true,
|
||||
value: handle ? l`by @${handle}` : undefined,
|
||||
hasPrev:
|
||||
!highlightedPublisher && Boolean(view.source?.uri),
|
||||
},
|
||||
]
|
||||
.filter(item => item.enabled && item.value)
|
||||
.map(item => {
|
||||
return (
|
||||
<>
|
||||
{item.hasPrev && (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
•
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{item.value}
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
</View>
|
||||
|
||||
{!hideSubscribe && (
|
||||
<SubscribeButton
|
||||
view={view}
|
||||
style={[!gtPhone && [a.w_full, a.justify_center]]}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
themeColors={themeColors}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{view.description && (
|
||||
<View style={[a.pt_sm]}>
|
||||
<Text style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
|
||||
{view.description}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function SubscribeButton({
|
||||
view,
|
||||
onPress,
|
||||
onLongPress,
|
||||
style,
|
||||
}: {
|
||||
view: AppBskyEmbedExternal.ViewExternal
|
||||
onPress?: () => void
|
||||
onLongPress?: () => void
|
||||
themeColors: ThemeColors
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const highlightedPublisher = useStandardSitePublisherConfig(view)
|
||||
const cta = highlightedPublisher
|
||||
? l`Subscribe on ${highlightedPublisher.name}`
|
||||
: l`View publication`
|
||||
|
||||
return (
|
||||
<Link
|
||||
shouldProxy
|
||||
to={view.source!.uri}
|
||||
label={cta}
|
||||
size="small"
|
||||
color="secondary_inverted"
|
||||
style={[style, {gap: 5}]}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}>
|
||||
{highlightedPublisher ? (
|
||||
<>
|
||||
<View style={[a.flex_row, a.align_center, {gap: 7}]}>
|
||||
<ButtonIcon icon={highlightedPublisher.Icon} size="lg" />
|
||||
<ButtonText>|</ButtonText>
|
||||
</View>
|
||||
<ButtonText>{cta}</ButtonText>
|
||||
</>
|
||||
) : (
|
||||
<ButtonText>{cta}</ButtonText>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function PublicationIcon({
|
||||
view,
|
||||
size,
|
||||
hovered,
|
||||
themeColors,
|
||||
}: {
|
||||
view: AppBskyEmbedExternal.ViewExternal
|
||||
size: number
|
||||
hovered?: boolean
|
||||
themeColors: ThemeColors
|
||||
}) {
|
||||
const opacity = hovered ? 0.6 : 0.2
|
||||
return view.source?.icon ? (
|
||||
<View>
|
||||
<UserAvatar
|
||||
noBorder
|
||||
type="labeler"
|
||||
size={size}
|
||||
avatar={view.source.icon}
|
||||
extraAviStyle={{borderRadius: a.rounded_sm.borderRadius}}
|
||||
/>
|
||||
<MediaInsetBorder
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
{
|
||||
borderColor: themeColors.accentForeground,
|
||||
opacity,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.rounded_sm,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: themeColors.accent,
|
||||
},
|
||||
]}>
|
||||
<StandardSite width={size * 0.8} fill={themeColors.accentForeground} />
|
||||
<MediaInsetBorder
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
{
|
||||
borderColor: themeColors.accentForeground,
|
||||
opacity,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function PublicationFooter({
|
||||
view,
|
||||
hideSubscribe,
|
||||
onPress,
|
||||
onLongPress,
|
||||
themeColors,
|
||||
author,
|
||||
}: {
|
||||
view: AppBskyEmbedExternal.ViewExternal
|
||||
hideSubscribe?: boolean
|
||||
themeColors: ThemeColors
|
||||
onPress?: () => void
|
||||
onLongPress?: () => void
|
||||
author: {did: string | null | undefined}
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const profileQuery = useProfileQuery({did: author.did ?? undefined})
|
||||
const handle = author.did ? profileQuery.data?.handle : undefined
|
||||
const highlightedPublisher = useMemo(() => {
|
||||
try {
|
||||
const u = new URL(view.source?.uri || '')
|
||||
if (u.host.endsWith('leaflet.pub')) {
|
||||
return 'Leaflet'
|
||||
} else if (u.host.endsWith('pckt.blog')) {
|
||||
return 'pckt'
|
||||
} else if (u.host.endsWith('offprint.app')) {
|
||||
return 'Offprint'
|
||||
}
|
||||
return null
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}, [view])
|
||||
|
||||
if (!view.source) return null
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.py_md,
|
||||
a.gap_md,
|
||||
gtPhone && [a.flex_row, a.gap_sm],
|
||||
]}
|
||||
testID="publication-embed-footer">
|
||||
<Link
|
||||
shouldProxy
|
||||
to={view.source.uri}
|
||||
label={l`Subscribe`}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
gtPhone && a.flex_1,
|
||||
]}>
|
||||
{({hovered}) => (
|
||||
<>
|
||||
<PublicationIcon
|
||||
view={view}
|
||||
size={32}
|
||||
hovered={hovered}
|
||||
themeColors={themeColors}
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.font_medium,
|
||||
t.atoms.text,
|
||||
hovered && a.underline,
|
||||
]}>
|
||||
{view.source?.title}
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
{[
|
||||
{
|
||||
type: 'domain',
|
||||
enabled: !highlightedPublisher,
|
||||
value: toNiceDomain(view.source?.uri || ''),
|
||||
hasPrev: false,
|
||||
},
|
||||
{
|
||||
type: 'author',
|
||||
enabled: true,
|
||||
value: handle ? l`by @${handle}` : undefined,
|
||||
hasPrev: !highlightedPublisher && Boolean(view.source?.uri),
|
||||
},
|
||||
]
|
||||
.filter(item => item.enabled && item.value)
|
||||
.map(item => {
|
||||
return (
|
||||
<>
|
||||
{item.hasPrev && (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
•
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{item.value}
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{!hideSubscribe && (
|
||||
<SubscribeButton
|
||||
view={view}
|
||||
style={[!gtPhone && [a.w_full, a.justify_center]]}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
themeColors={themeColors}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function colorRGBToHex(
|
||||
rgb: AppBskyEmbedExternal.ViewExternalSourceTheme['accentRGB'],
|
||||
): string {
|
||||
if (!rgb) return '#000000'
|
||||
const {r, g, b} = rgb
|
||||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
|
||||
}
|
||||
@@ -22,6 +22,10 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {GalleryBleed} from '#/components/images/Gallery'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {
|
||||
isStandardSiteEmbed,
|
||||
StandardSiteEmbed,
|
||||
} from '#/components/Post/Embed/StandardSiteEmbed'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||
import {SubtleHover} from '#/components/SubtleHover'
|
||||
@@ -32,8 +36,6 @@ import {
|
||||
parseEmbed,
|
||||
} from '#/types/bsky/post'
|
||||
import {ExternalEmbed} from './ExternalEmbed'
|
||||
import {PublicationEmbed} from './ExternalEmbed/PublicationEmbed'
|
||||
import {type PublicationViewExternal} from './ExternalEmbed/PublicationEmbed/types'
|
||||
import {ModeratedFeedEmbed} from './FeedEmbed'
|
||||
import {ImageEmbed} from './ImageEmbed'
|
||||
import {ModeratedListEmbed} from './ListEmbed'
|
||||
@@ -97,16 +99,13 @@ function MediaEmbed({
|
||||
)
|
||||
}
|
||||
case 'link': {
|
||||
const external = embed.view.external as PublicationViewExternal
|
||||
if (external.source) {
|
||||
if (isStandardSiteEmbed(embed.view.external)) {
|
||||
return (
|
||||
<ContentHider
|
||||
modui={rest.moderation?.ui('contentMedia')}
|
||||
activeStyle={[a.mt_sm]}>
|
||||
<PublicationEmbed
|
||||
link={external}
|
||||
source={external.source}
|
||||
onOpen={rest.onOpen}
|
||||
<StandardSiteEmbed
|
||||
view={embed.view.external}
|
||||
style={[a.mt_sm, rest.style]}
|
||||
/>
|
||||
</ContentHider>
|
||||
|
||||
Reference in New Issue
Block a user