614 lines
17 KiB
TypeScript
614 lines
17 KiB
TypeScript
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 {UserAvatar} from '#/view/com/util/UserAvatar'
|
|
import {atoms as a, useBreakpoints, useTheme, utils} 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 {PublicationMetaRow} from '#/components/Post/Embed/StandardSiteEmbed/PublicationMetaRow'
|
|
import {StandardSiteThemeProvider} from '#/components/Post/Embed/StandardSiteEmbed/StandardSiteThemeProvider'
|
|
import {Text} from '#/components/Typography'
|
|
import {IS_NATIVE} from '#/env'
|
|
|
|
export type ThemeColors = {
|
|
custom: boolean
|
|
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 custom = false
|
|
let accent = t.atoms.text.color
|
|
let accentForeground = t.atoms.text_inverted.color
|
|
const {accentRGB, accentForegroundRGB} = view.source?.theme || {}
|
|
if (accentRGB && accentForegroundRGB) {
|
|
custom = true
|
|
accent = utils.rgbToHex(accentRGB.r, accentRGB.g, accentRGB.b)
|
|
accentForeground = utils.rgbToHex(
|
|
accentForegroundRGB.r,
|
|
accentForegroundRGB.g,
|
|
accentForegroundRGB.b,
|
|
)
|
|
}
|
|
return {custom, 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()
|
|
|
|
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>
|
|
<PublicationMetaRow view={view} author={author} />
|
|
</View>
|
|
</>
|
|
</View>
|
|
|
|
{!hideSubscribe && (
|
|
<SubscribeButton
|
|
view={view}
|
|
style={[!gtPhone && [a.w_full, a.justify_center]]}
|
|
onPress={onPress}
|
|
onLongPress={onLongPress}
|
|
/>
|
|
)}
|
|
</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
|
|
style?: StyleProp<ViewStyle>
|
|
}) {
|
|
const {t: l} = useLingui()
|
|
const highlightedPublisher = useStandardSitePublisherConfig(view)
|
|
const cta = highlightedPublisher
|
|
? l`Subscribe on ${highlightedPublisher.name}`
|
|
: l`View publication`
|
|
|
|
return (
|
|
<StandardSiteThemeProvider view={view}>
|
|
<Link
|
|
shouldProxy
|
|
to={view.source!.uri}
|
|
label={cta}
|
|
size="small"
|
|
color="secondary_inverted"
|
|
style={[style, a.gap_sm]}
|
|
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>
|
|
</StandardSiteThemeProvider>
|
|
)
|
|
}
|
|
|
|
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()
|
|
|
|
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>
|
|
<PublicationMetaRow view={view} author={author} />
|
|
</View>
|
|
</>
|
|
)}
|
|
</Link>
|
|
|
|
{!hideSubscribe && (
|
|
<SubscribeButton
|
|
view={view}
|
|
style={[!gtPhone && [a.w_full, a.justify_center]]}
|
|
onPress={onPress}
|
|
onLongPress={onLongPress}
|
|
/>
|
|
)}
|
|
</View>
|
|
)
|
|
}
|