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 {StandardSite} from '#/components/icons/community/StandardSite' 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 {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 }) => { 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?.() } const onLongPress = IS_NATIVE ? () => { if (view.uri) { playHaptic('Heavy') void shareUrl(view.uri) } } : undefined const onPressPublication = () => { playHaptic('Light') onEmbedInteractionCallback?.() } const onLongPressPublication = IS_NATIVE ? () => { if (view.source?.uri) { playHaptic('Heavy') void shareUrl(view.source.uri) } } : undefined if (isStandardPublication) { return ( ) } return ( {/* * The article is an in-flow `Link` wrapping the content so the iOS peek * menu has something to lift. The publication footer below lives outside * it - it has its own link (to the publication) and Subscribe button, so * it can't be nested inside the article's peek. */} {() => ( {imageUri ? ( ) : undefined} {view.title} {view.description ? ( {view.description} ) : undefined} {isStandard && (view.createdAt || view.readingTime) && ( {view.createdAt && ( {niceDate(i18n, view.createdAt, 'long', 'none')} )} {view.readingTime && ( {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.`, })} )} )} {!view.source && ( )} )} {view.source && ( )} ) } 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 }) { 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.source?.title} {gtPhone && ( )} {view.description && ( {view.description} )} {!gtPhone && ( )} ) } export function SubscribeButton({ preview, view, style, onEmbedInteractionCallback, }: ssTypes.CommonProps & ssTypes.PreviewProps & ssTypes.PublicApiProps & { style?: StyleProp }) { 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) { } else { } } const onLongPress = IS_NATIVE ? () => { if (view.source?.uri) { playHaptic('Heavy') void shareUrl(view.source.uri) if (highlightedPublisher) { } else { } } } : undefined const button = ( {highlightedPublisher ? ( <> {cta} ) : ( <> {cta} )} ) if (!useCustomTheme) { return button } return ( {button} ) } function PublicationIcon({ view, size, themeColors, }: ssTypes.CommonProps & { size: number interacted?: boolean themeColors: ssTypes.ThemeColors }) { const t = useTheme() if (!view.source) return null const icon = view.source?.icon ? ( ) : ( {[...view.source.title][0] ?? ''} ) return ( {icon} ) } 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.source?.title} ) }