[APP-2160] Build new standard.site link card UI (#10468)
Co-authored-by: vineyardbovines <spencer@thepope.dev> Co-authored-by: Eric Bailey <git@esb.lol> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import {Fragment, type ReactNode} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyEmbedExternal, 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 {
|
||||
isStandardSiteDocumentUri,
|
||||
isStandardSitePublicationUri,
|
||||
} from '#/components/Post/Embed/StandardSiteEmbed/utils'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function StandardSiteMetaRow({
|
||||
type = 'document',
|
||||
view,
|
||||
onInteractWithin,
|
||||
onInteractWithout,
|
||||
}: {
|
||||
type?: 'document' | 'publication'
|
||||
view: AppBskyEmbedExternal.ViewExternal
|
||||
onInteractWithin: () => void
|
||||
onInteractWithout: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const highlightedPublisher = !!matchStandardSitePublisher(view)
|
||||
const didsFromRecords =
|
||||
view.associatedRefs
|
||||
?.filter(
|
||||
type === 'document'
|
||||
? isStandardSiteDocumentUri
|
||||
: isStandardSitePublicationUri,
|
||||
)
|
||||
.map(ref => new AtUri(ref.uri).host) || []
|
||||
// atm should only be one docment
|
||||
const authorDid = didsFromRecords.at(0)
|
||||
const authorProfile = authorDid
|
||||
? view.associatedProfiles?.find(p => p.did === authorDid)
|
||||
: undefined
|
||||
const articleDomain = toNiceDomain(view.uri)
|
||||
const articlePublisher = matchStandardSitePublisherByUri(view.uri)
|
||||
const DomainIcon = articlePublisher?.Icon ?? StandardSite
|
||||
const metaTextStyle = [
|
||||
a.text_xs,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]
|
||||
|
||||
const items: {key: string; node: ReactNode}[] = []
|
||||
|
||||
if (!highlightedPublisher) {
|
||||
items.push({
|
||||
key: 'domain',
|
||||
node: (
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<DomainIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
<Text numberOfLines={1} style={metaTextStyle}>
|
||||
{articleDomain}
|
||||
</Text>
|
||||
</View>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if (authorProfile) {
|
||||
items.push({
|
||||
key: 'author',
|
||||
node: (
|
||||
<Text numberOfLines={1} style={metaTextStyle}>
|
||||
<Trans>
|
||||
by{' '}
|
||||
<InlineLinkText
|
||||
label={l`View @${authorProfile.handle}'s profile`}
|
||||
to={makeProfileLink(authorProfile)}
|
||||
style={metaTextStyle}
|
||||
onPress={e => {
|
||||
// this link is nested, yes it's not ideal
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onMouseEnter={onInteractWithin}
|
||||
onMouseLeave={onInteractWithout}>
|
||||
@{authorProfile.handle}
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
{items.map((item, i) => (
|
||||
<Fragment key={item.key}>
|
||||
{i > 0 && <Text style={metaTextStyle}>•</Text>}
|
||||
{item.node}
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {type AppBskyEmbedExternal} from '@atproto/api'
|
||||
|
||||
import {Context, useAlf, utils} from '#/alf'
|
||||
|
||||
/**
|
||||
* Overrides only the values needed for `secondary_inverted` buttons atm.
|
||||
*
|
||||
* Renders the alf Context directly (rather than nesting a ThemeProvider) so
|
||||
* that font-scale and other parent state stay live in the subtree.
|
||||
*/
|
||||
export function StandardSiteThemeProvider({
|
||||
view,
|
||||
children,
|
||||
}: {
|
||||
view: AppBskyEmbedExternal.ViewExternal
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const alf = useAlf()
|
||||
const {accentRGB, accentForegroundRGB} = view.source?.theme || {}
|
||||
if (!accentRGB || !accentForegroundRGB) return children
|
||||
|
||||
const accent = utils.rgbToHex(accentRGB.r, accentRGB.g, accentRGB.b)
|
||||
const accentForeground = utils.rgbToHex(
|
||||
accentForegroundRGB.r,
|
||||
accentForegroundRGB.g,
|
||||
accentForegroundRGB.b,
|
||||
)
|
||||
const atomsOverride = {
|
||||
text_inverted: {color: accentForeground},
|
||||
}
|
||||
const paletteOverride = {
|
||||
contrast_975: utils.darken(accent, 5), // hover
|
||||
contrast_900: accent, // bg
|
||||
contrast_600: utils.lighten(accent, 5), // disabled bg
|
||||
contrast_300: accentForeground, // disabled text
|
||||
}
|
||||
|
||||
const themes = {
|
||||
...alf.themes,
|
||||
lightPalette: {...alf.themes.lightPalette, ...paletteOverride},
|
||||
darkPalette: {...alf.themes.darkPalette, ...paletteOverride},
|
||||
dimPalette: {...alf.themes.dimPalette, ...paletteOverride},
|
||||
light: {
|
||||
...alf.themes.light,
|
||||
atoms: {...alf.themes.light.atoms, ...atomsOverride},
|
||||
palette: {...alf.themes.light.palette, ...paletteOverride},
|
||||
},
|
||||
dark: {
|
||||
...alf.themes.dark,
|
||||
atoms: {...alf.themes.dark.atoms, ...atomsOverride},
|
||||
palette: {...alf.themes.dark.palette, ...paletteOverride},
|
||||
},
|
||||
dim: {
|
||||
...alf.themes.dim,
|
||||
atoms: {...alf.themes.dim.atoms, ...atomsOverride},
|
||||
palette: {...alf.themes.dim.palette, ...paletteOverride},
|
||||
},
|
||||
}
|
||||
|
||||
const value = {
|
||||
...alf,
|
||||
themes,
|
||||
theme: themes[alf.themeName],
|
||||
}
|
||||
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
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 {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} from '#/alf'
|
||||
import {ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
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 {isStandardSitePublicationEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
|
||||
import {Text} from '#/components/Typography'
|
||||
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 = ({
|
||||
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 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: 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: interactedWithin,
|
||||
onIn: onInteractWithin,
|
||||
onOut: onInteractWithout,
|
||||
} = useInteractionState()
|
||||
|
||||
const onPress = () => {
|
||||
playHaptic('Light')
|
||||
onOpen?.()
|
||||
}
|
||||
|
||||
const onLongPress = () => {
|
||||
if (view.uri && IS_NATIVE) {
|
||||
playHaptic('Heavy')
|
||||
shareUrl(view.uri)
|
||||
}
|
||||
}
|
||||
|
||||
if (isStandardPublication) {
|
||||
return (
|
||||
<PublicationCard
|
||||
hideSubscribe={hideSubscribe}
|
||||
view={view}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
style={style}
|
||||
themeColors={themeColors}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.rounded_lg,
|
||||
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: maybeHovered}) => {
|
||||
const hovered = maybeHovered && !interactedWithin
|
||||
return (
|
||||
<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.py_sm]}>
|
||||
<StandardSiteMetaRow
|
||||
view={view}
|
||||
onInteractWithin={onInteractWithin}
|
||||
onInteractWithout={onInteractWithout}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}}
|
||||
</Link>
|
||||
|
||||
{view.source && (
|
||||
<>
|
||||
<View style={[a.px_md]}>
|
||||
<Divider />
|
||||
</View>
|
||||
<PublicationFooter
|
||||
view={view}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
themeColors={themeColors}
|
||||
hideSubscribe={hideSubscribe}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function PublicationCard({
|
||||
view,
|
||||
hideSubscribe,
|
||||
onPress,
|
||||
onLongPress,
|
||||
themeColors,
|
||||
style,
|
||||
}: {
|
||||
view: AppBskyEmbedExternal.ViewExternal
|
||||
hideSubscribe?: boolean
|
||||
onPress?: () => void
|
||||
onLongPress?: () => void
|
||||
themeColors: ThemeColors
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const {
|
||||
state: interactedWithin,
|
||||
onIn: onInteractWithin,
|
||||
onOut: onInteractWithout,
|
||||
} = useInteractionState()
|
||||
|
||||
if (!view.source) return null
|
||||
|
||||
return (
|
||||
<Link
|
||||
shouldProxy
|
||||
to={view.source.uri}
|
||||
label={l`View publication`}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}>
|
||||
{({hovered: maybeHovered}) => {
|
||||
const hovered = maybeHovered && !interactedWithin
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.rounded_lg,
|
||||
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>
|
||||
<StandardSiteMetaRow
|
||||
type="publication"
|
||||
view={view}
|
||||
onInteractWithin={onInteractWithin}
|
||||
onInteractWithout={onInteractWithout}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!hideSubscribe && gtPhone && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!hideSubscribe && !gtPhone && (
|
||||
<View style={[view.description && a.pt_sm]}>
|
||||
<SubscribeButton
|
||||
view={view}
|
||||
style={[!gtPhone && [a.w_full, a.justify_center]]}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
/>
|
||||
</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 = matchStandardSitePublisher(view)
|
||||
const cta = highlightedPublisher
|
||||
? l`Subscribe on ${highlightedPublisher.name}`
|
||||
: l`View publication`
|
||||
|
||||
if (!view.source) return null
|
||||
|
||||
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
|
||||
if (!view.source) return null
|
||||
return view.source?.icon ? (
|
||||
<View>
|
||||
<UserAvatar
|
||||
noBorder
|
||||
type="labeler"
|
||||
size={size}
|
||||
avatar={view.source.icon}
|
||||
extraAviStyle={PUBLICATION_AVATAR_STYLE}
|
||||
/>
|
||||
<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,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.text_xl, a.font_bold, {color: themeColors.accentForeground}]}>
|
||||
{[...view.source.title][0] ?? ''}
|
||||
</Text>
|
||||
<MediaInsetBorder
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
{
|
||||
borderColor: themeColors.accentForeground,
|
||||
opacity,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function PublicationFooter({
|
||||
view,
|
||||
hideSubscribe,
|
||||
onPress,
|
||||
onLongPress,
|
||||
themeColors,
|
||||
}: {
|
||||
view: AppBskyEmbedExternal.ViewExternal
|
||||
hideSubscribe?: boolean
|
||||
themeColors: ThemeColors
|
||||
onPress?: () => void
|
||||
onLongPress?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const {
|
||||
state: maybeHovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
const {
|
||||
state: interactedWithin,
|
||||
onIn: onInteractWithin,
|
||||
onOut: onInteractWithout,
|
||||
} = useInteractionState()
|
||||
const hovered = maybeHovered && !interactedWithin
|
||||
|
||||
if (!view.source) return null
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.p_md,
|
||||
a.gap_md,
|
||||
gtPhone && [a.flex_row, a.gap_sm],
|
||||
]}
|
||||
testID="publication-embed-footer"
|
||||
// @ts-ignore it's Fine™
|
||||
onMouseEnter={onHoverIn}
|
||||
onMouseLeave={onHoverOut}>
|
||||
<Link
|
||||
shouldProxy
|
||||
to={view.source.uri}
|
||||
label={l`View publication`}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
gtPhone && a.flex_1,
|
||||
]}>
|
||||
<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>
|
||||
<StandardSiteMetaRow
|
||||
type="publication"
|
||||
view={view}
|
||||
onInteractWithin={onInteractWithin}
|
||||
onInteractWithout={onInteractWithout}
|
||||
/>
|
||||
</View>
|
||||
</Link>
|
||||
|
||||
{!hideSubscribe && (
|
||||
<SubscribeButton
|
||||
view={view}
|
||||
style={[!gtPhone && [a.w_full, a.justify_center]]}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {type AppBskyEmbedExternal} from '@atproto/api'
|
||||
|
||||
import {Leaflet} from '#/components/icons/community/Leaflet'
|
||||
import {Offprint} from '#/components/icons/community/Offprint'
|
||||
import {Pckt} from '#/components/icons/community/Pckt'
|
||||
|
||||
export type StandardSitePublisher = {
|
||||
host: string
|
||||
name: string
|
||||
Icon: typeof Leaflet
|
||||
}
|
||||
|
||||
const STANDARD_SITE_PUBLISHERS: StandardSitePublisher[] = [
|
||||
{host: 'leaflet.pub', name: 'Leaflet', Icon: Leaflet},
|
||||
{host: 'pckt.blog', name: 'pckt', Icon: Pckt},
|
||||
{host: 'offprint.app', name: 'Offprint', Icon: Offprint},
|
||||
]
|
||||
|
||||
function hostFromUri(uri: string | undefined): string | null {
|
||||
try {
|
||||
return new URL(uri || '').host
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getStandardSitePublisherHost(
|
||||
view: AppBskyEmbedExternal.ViewExternal,
|
||||
): string | null {
|
||||
return hostFromUri(view.source?.uri)
|
||||
}
|
||||
|
||||
export function hostMatches(host: string, target: string): boolean {
|
||||
return host === target || host.endsWith('.' + target)
|
||||
}
|
||||
|
||||
function matchByHost(host: string | null): StandardSitePublisher | null {
|
||||
if (!host) return null
|
||||
return STANDARD_SITE_PUBLISHERS.find(p => hostMatches(host, p.host)) ?? null
|
||||
}
|
||||
|
||||
export function matchStandardSitePublisher(
|
||||
view: AppBskyEmbedExternal.ViewExternal,
|
||||
): StandardSitePublisher | null {
|
||||
return matchByHost(getStandardSitePublisherHost(view))
|
||||
}
|
||||
|
||||
export function matchStandardSitePublisherByUri(
|
||||
uri: string | undefined,
|
||||
): StandardSitePublisher | null {
|
||||
return matchByHost(hostFromUri(uri))
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {type AppBskyEmbedExternal} from '@atproto/api'
|
||||
|
||||
import {isStandardSiteEmbed, isStandardSitePublicationEmbed} from './utils'
|
||||
|
||||
function makeView(
|
||||
partial: Record<string, unknown>,
|
||||
): AppBskyEmbedExternal.ViewExternal {
|
||||
return {
|
||||
uri: 'https://example.com/post',
|
||||
title: 'title',
|
||||
description: 'description',
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('isStandardSiteEmbed', () => {
|
||||
it('returns true when any associated ref is in the site.standard.* namespace', () => {
|
||||
const view = makeView({
|
||||
associatedRefs: [{uri: 'at://did:plc:abc/site.standard.publication/foo'}],
|
||||
})
|
||||
expect(isStandardSiteEmbed(view)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no associated refs are in the site.standard.* namespace', () => {
|
||||
const view = makeView({
|
||||
associatedRefs: [{uri: 'at://did:plc:abc/app.bsky.feed.post/foo'}],
|
||||
})
|
||||
expect(isStandardSiteEmbed(view)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns falsy when associatedRefs is missing', () => {
|
||||
expect(isStandardSiteEmbed(makeView({}))).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isStandardSitePublicationEmbed', () => {
|
||||
it('returns true with at least one publication ref and no document refs', () => {
|
||||
const view = makeView({
|
||||
associatedRefs: [{uri: 'at://did:plc:abc/site.standard.publication/foo'}],
|
||||
})
|
||||
expect(isStandardSitePublicationEmbed(view)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when any ref is a document', () => {
|
||||
const view = makeView({
|
||||
associatedRefs: [
|
||||
{uri: 'at://did:plc:abc/site.standard.publication/foo'},
|
||||
{uri: 'at://did:plc:abc/site.standard.document/bar'},
|
||||
],
|
||||
})
|
||||
expect(isStandardSitePublicationEmbed(view)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when there are no publication refs', () => {
|
||||
const view = makeView({
|
||||
associatedRefs: [{uri: 'at://did:plc:abc/site.standard.other/foo'}],
|
||||
})
|
||||
expect(isStandardSitePublicationEmbed(view)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns falsy for an empty associatedRefs array', () => {
|
||||
expect(isStandardSitePublicationEmbed(makeView({associatedRefs: []}))).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns falsy when associatedRefs is missing', () => {
|
||||
expect(isStandardSitePublicationEmbed(makeView({}))).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
type AppBskyEmbedExternal,
|
||||
AtUri,
|
||||
type ComAtprotoRepoStrongRef,
|
||||
} from '@atproto/api'
|
||||
|
||||
export function isStandardSiteDocumentUri(ref: ComAtprotoRepoStrongRef.Main) {
|
||||
return new AtUri(ref.uri).collection.startsWith('site.standard.document')
|
||||
}
|
||||
|
||||
export function isStandardSitePublicationUri(
|
||||
ref: ComAtprotoRepoStrongRef.Main,
|
||||
) {
|
||||
return new AtUri(ref.uri).collection.startsWith('site.standard.publication')
|
||||
}
|
||||
|
||||
export function isStandardSiteUri(ref: ComAtprotoRepoStrongRef.Main) {
|
||||
return new AtUri(ref.uri).collection.startsWith('site.standard.')
|
||||
}
|
||||
|
||||
export function isStandardSiteEmbed(view: AppBskyEmbedExternal.ViewExternal) {
|
||||
return view.associatedRefs?.some(ref => isStandardSiteUri(ref))
|
||||
}
|
||||
|
||||
export function isStandardSitePublicationEmbed(
|
||||
view: AppBskyEmbedExternal.ViewExternal,
|
||||
) {
|
||||
return (
|
||||
view.associatedRefs?.some(
|
||||
ref => new AtUri(ref.uri).collection === 'site.standard.publication',
|
||||
) &&
|
||||
view.associatedRefs.every(
|
||||
ref => new AtUri(ref.uri).collection !== 'site.standard.document',
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,8 @@ 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 {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed'
|
||||
import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||
import {SubtleHover} from '#/components/SubtleHover'
|
||||
@@ -95,6 +97,18 @@ function MediaEmbed({
|
||||
)
|
||||
}
|
||||
case 'link': {
|
||||
if (isStandardSiteEmbed(embed.view.external)) {
|
||||
return (
|
||||
<ContentHider
|
||||
modui={rest.moderation?.ui('contentMedia')}
|
||||
activeStyle={[a.mt_sm]}>
|
||||
<StandardSiteEmbed
|
||||
view={embed.view.external}
|
||||
style={[a.mt_sm, rest.style]}
|
||||
/>
|
||||
</ContentHider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ContentHider
|
||||
modui={rest.moderation?.ui('contentMedia')}
|
||||
|
||||
Reference in New Issue
Block a user