diff --git a/assets/icons/community/leaflet.svg b/assets/icons/community/leaflet.svg
new file mode 100644
index 0000000000..e9a65c154d
--- /dev/null
+++ b/assets/icons/community/leaflet.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/community/offprint.svg b/assets/icons/community/offprint.svg
new file mode 100644
index 0000000000..605988c36d
--- /dev/null
+++ b/assets/icons/community/offprint.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/community/pckt-full.svg b/assets/icons/community/pckt-full.svg
new file mode 100644
index 0000000000..245c176f80
--- /dev/null
+++ b/assets/icons/community/pckt-full.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/community/pckt.svg b/assets/icons/community/pckt.svg
new file mode 100644
index 0000000000..e0aa775704
--- /dev/null
+++ b/assets/icons/community/pckt.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/community/standard-site.svg b/assets/icons/community/standard-site.svg
new file mode 100644
index 0000000000..64dd20c3bd
--- /dev/null
+++ b/assets/icons/community/standard-site.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index a61c037e08..818698a4ce 100644
--- a/package.json
+++ b/package.json
@@ -92,7 +92,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
- "@atproto/api": "0.20.4",
+ "@atproto/api": "0.20.5",
"@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e76cdb648c..a57daefd8e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -242,8 +242,8 @@ importers:
.:
dependencies:
'@atproto/api':
- specifier: 0.20.4
- version: 0.20.4
+ specifier: 0.20.5
+ version: 0.20.5
'@atproto/syntax':
specifier: 0.6.1
version: 0.6.1
@@ -889,8 +889,8 @@ packages:
graphql:
optional: true
- '@atproto/api@0.20.4':
- resolution: {integrity: sha512-1hdMZDLBqPdhK5CYZCsd5T0PK12LhFxHiKge3q1tS7cDd2a553+NhG5YOTtmCke34xkw4MNMJbC4K8UKL+J5Fw==}
+ '@atproto/api@0.20.5':
+ resolution: {integrity: sha512-VqkRYKR9vRRk36NhtytJz5TPhNtR1hczCcfM+bWoOK6MBvWUT8HwelufrycFAIbFIH7n4ws+5UbnLYpTIBQZ6Q==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
@@ -9532,7 +9532,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
- '@atproto/api@0.20.4':
+ '@atproto/api@0.20.5':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
diff --git a/src/alf/index.tsx b/src/alf/index.tsx
index 1c7b080d87..0cb5ceb3f7 100644
--- a/src/alf/index.tsx
+++ b/src/alf/index.tsx
@@ -1,5 +1,5 @@
import {createContext, useCallback, useContext, useMemo, useState} from 'react'
-import {type Theme, type ThemeName} from '@bsky.app/alf'
+import {type Theme, type ThemeName, utils as baseUtils} from '@bsky.app/alf'
import {
computeFontScaleMultiplier,
@@ -9,14 +9,10 @@ import {
setFontScale as persistFontScale,
} from '#/alf/fonts'
import {themes} from '#/alf/themes'
+import {darken, lighten, rgbToHex} from '#/alf/util/colorGeneration'
import {type Device} from '#/storage'
-export {
- type TextStyleProp,
- type Theme,
- utils,
- type ViewStyleProp,
-} from '@bsky.app/alf'
+export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
export {atoms} from '#/alf/atoms'
export * from '#/alf/breakpoints'
export * from '#/alf/fonts'
@@ -25,6 +21,12 @@ export * from '#/alf/util/flatten'
export * from '#/alf/util/platform'
export * from '#/alf/util/themeSelector'
export * from '#/alf/util/useGutters'
+export const utils = {
+ ...baseUtils,
+ rgbToHex,
+ lighten,
+ darken,
+}
export type Alf = {
themeName: ThemeName
@@ -64,7 +66,11 @@ Context.displayName = 'AlfContext'
export function ThemeProvider({
children,
theme: themeName,
-}: React.PropsWithChildren<{theme: ThemeName}>) {
+ themesOverride,
+}: React.PropsWithChildren<{
+ theme: ThemeName
+ themesOverride?: Partial
+}>) {
const [fontScale, setFontScale] = useState(() =>
getFontScale(),
)
@@ -90,11 +96,15 @@ export function ThemeProvider({
[setFontFamily],
)
- const value = useMemo(
- () => ({
- themes,
+ const value = useMemo(() => {
+ const t = {
+ ...themes,
+ ...themesOverride,
+ }
+ return {
+ themes: t,
themeName: themeName,
- theme: themes[themeName],
+ theme: t[themeName],
fonts: {
scale: fontScale,
scaleMultiplier: fontScaleMultiplier,
@@ -103,16 +113,16 @@ export function ThemeProvider({
setFontFamily: setFontFamilyAndPersist,
},
flags: {},
- }),
- [
- themeName,
- fontScale,
- setFontScaleAndPersist,
- fontFamily,
- setFontFamilyAndPersist,
- fontScaleMultiplier,
- ],
- )
+ }
+ }, [
+ themeName,
+ fontScale,
+ setFontScaleAndPersist,
+ fontFamily,
+ setFontFamilyAndPersist,
+ fontScaleMultiplier,
+ themesOverride,
+ ])
return {children}
}
diff --git a/src/alf/util/colorGeneration.test.ts b/src/alf/util/colorGeneration.test.ts
new file mode 100644
index 0000000000..c4a2b0bbb5
--- /dev/null
+++ b/src/alf/util/colorGeneration.test.ts
@@ -0,0 +1,94 @@
+import {darken, hexToRgb, lighten, rgbToHex} from './colorGeneration'
+
+describe('hexToRgb', () => {
+ it('parses 6-digit hex', () => {
+ expect(hexToRgb('#abcdef')).toEqual({r: 0xab, g: 0xcd, b: 0xef})
+ })
+
+ it('parses 6-digit hex without leading #', () => {
+ expect(hexToRgb('abcdef')).toEqual({r: 0xab, g: 0xcd, b: 0xef})
+ })
+
+ it('parses 3-digit shorthand hex', () => {
+ expect(hexToRgb('#abc')).toEqual({r: 0xaa, g: 0xbb, b: 0xcc})
+ })
+
+ it('parses 8-digit hex by dropping the alpha channel', () => {
+ expect(hexToRgb('#aabbccdd')).toEqual({r: 0xaa, g: 0xbb, b: 0xcc})
+ })
+
+ it('handles uppercase digits', () => {
+ expect(hexToRgb('#ABCDEF')).toEqual({r: 0xab, g: 0xcd, b: 0xef})
+ })
+
+ it('returns null for 4-digit shorthand', () => {
+ expect(hexToRgb('#abcd')).toBeNull()
+ })
+
+ it('returns null for non-hex characters', () => {
+ expect(hexToRgb('#zzzzzz')).toBeNull()
+ })
+
+ it('returns null for non-hex words', () => {
+ expect(hexToRgb('blue')).toBeNull()
+ })
+
+ it('returns null for the empty string', () => {
+ expect(hexToRgb('')).toBeNull()
+ })
+
+ it('returns null for unexpected lengths', () => {
+ expect(hexToRgb('#ab')).toBeNull()
+ expect(hexToRgb('#abcde')).toBeNull()
+ expect(hexToRgb('#abcdefg')).toBeNull()
+ })
+})
+
+describe('rgbToHex', () => {
+ it('formats integer channels', () => {
+ expect(rgbToHex(0xab, 0xcd, 0xef)).toBe('#abcdef')
+ })
+
+ it('rounds floating-point channels', () => {
+ expect(rgbToHex(170.4, 187.6, 204.5)).toBe('#aabccd')
+ })
+
+ it('clamps below zero to 00', () => {
+ expect(rgbToHex(-10, 0, 0)).toBe('#000000')
+ })
+
+ it('clamps above 255 to ff', () => {
+ expect(rgbToHex(300, 255, 255)).toBe('#ffffff')
+ })
+
+ it('zero-pads short hex output', () => {
+ expect(rgbToHex(0, 0, 0)).toBe('#000000')
+ })
+})
+
+describe('lighten / darken', () => {
+ it('lighten increases lightness', () => {
+ expect(lighten('#808080', 10)).toBe('#9a9a9a')
+ })
+
+ it('darken decreases lightness', () => {
+ expect(darken('#808080', 10)).toBe('#676767')
+ })
+
+ it('lighten clamps at white', () => {
+ expect(lighten('#ffffff', 50)).toBe('#ffffff')
+ })
+
+ it('darken clamps at black', () => {
+ expect(darken('#000000', 50)).toBe('#000000')
+ })
+
+ it('lighten by zero is a no-op', () => {
+ expect(lighten('#abcdef', 0)).toBe('#abcdef')
+ })
+
+ it('returns the input unchanged for invalid hex', () => {
+ expect(lighten('not-a-color', 10)).toBe('not-a-color')
+ expect(darken('#zzz', 10)).toBe('#zzz')
+ })
+})
diff --git a/src/alf/util/colorGeneration.ts b/src/alf/util/colorGeneration.ts
index 467270676a..85659af25f 100644
--- a/src/alf/util/colorGeneration.ts
+++ b/src/alf/util/colorGeneration.ts
@@ -6,3 +6,133 @@ export const BLUE_HUE = 211
* @deprecated use `utils.alpha` from `@bsky.app/alf` instead
*/
export const transparentifyColor = utils.alpha
+
+/**
+ * Lighten a hex color by `amount` percentage points of HSL lightness (0-100).
+ * If `hex` is not a valid hex color, returns the input unchanged.
+ */
+export function lighten(hex: string, amount: number): string {
+ return adjustLightness(hex, amount)
+}
+
+/**
+ * Darken a hex color by `amount` percentage points of HSL lightness (0-100).
+ * If `hex` is not a valid hex color, returns the input unchanged.
+ */
+export function darken(hex: string, amount: number): string {
+ return adjustLightness(hex, -amount)
+}
+
+function adjustLightness(hex: string, delta: number): string {
+ try {
+ const rgb = hexToRgb(hex)
+ if (!rgb) return hex
+ const {h, s, l} = rgbToHsl(rgb.r, rgb.g, rgb.b)
+ const next = clamp(l + delta, 0, 100)
+ const out = hslToRgb(h, s, next)
+ return rgbToHex(out.r, out.g, out.b)
+ } catch {
+ return hex
+ }
+}
+
+const HEX_PATTERN = /^([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/
+
+export function hexToRgb(
+ hex: string,
+): {r: number; g: number; b: number} | null {
+ try {
+ if (typeof hex !== 'string') return null
+ const h = hex.startsWith('#') ? hex.slice(1) : hex
+ if (!HEX_PATTERN.test(h)) return null
+ // 8-digit hex carries an alpha channel we don't use; drop it
+ const rgb = h.length === 8 ? h.slice(0, 6) : h
+ const expanded =
+ rgb.length === 3
+ ? rgb
+ .split('')
+ .map(c => c + c)
+ .join('')
+ : rgb
+ const num = parseInt(expanded, 16)
+ return {
+ r: (num >> 16) & 255,
+ g: (num >> 8) & 255,
+ b: num & 255,
+ }
+ } catch {
+ return null
+ }
+}
+
+export function rgbToHex(r: number, g: number, b: number): string {
+ const c = (n: number) => clamp(Math.round(n), 0, 255)
+ return `#${((1 << 24) + (c(r) << 16) + (c(g) << 8) + c(b))
+ .toString(16)
+ .slice(1)}`
+}
+
+function rgbToHsl(
+ r: number,
+ g: number,
+ b: number,
+): {h: number; s: number; l: number} {
+ r /= 255
+ g /= 255
+ b /= 255
+ const max = Math.max(r, g, b)
+ const min = Math.min(r, g, b)
+ const l = (max + min) / 2
+ let h = 0
+ let s = 0
+ if (max !== min) {
+ const d = max - min
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
+ switch (max) {
+ case r:
+ h = (g - b) / d + (g < b ? 6 : 0)
+ break
+ case g:
+ h = (b - r) / d + 2
+ break
+ case b:
+ h = (r - g) / d + 4
+ break
+ }
+ h /= 6
+ }
+ return {h: h * 360, s: s * 100, l: l * 100}
+}
+
+function hslToRgb(
+ h: number,
+ s: number,
+ l: number,
+): {r: number; g: number; b: number} {
+ h /= 360
+ s /= 100
+ l /= 100
+ if (s === 0) {
+ const v = l * 255
+ return {r: v, g: v, b: v}
+ }
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s
+ const p = 2 * l - q
+ const f = (t: number) => {
+ if (t < 0) t += 1
+ if (t > 1) t -= 1
+ if (t < 1 / 6) return p + (q - p) * 6 * t
+ if (t < 1 / 2) return q
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
+ return p
+ }
+ return {
+ r: f(h + 1 / 3) * 255,
+ g: f(h) * 255,
+ b: f(h - 1 / 3) * 255,
+ }
+}
+
+function clamp(n: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, n))
+}
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index 5e111891bd..d667823a5d 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -326,6 +326,8 @@ export type InlineLinkProps = React.PropsWithChildren<
disableUnderline?: boolean
title?: TextProps['title']
overridePresentation?: boolean
+ onMouseEnter?: () => void
+ onMouseLeave?: () => void
}
>
@@ -388,8 +390,14 @@ export function InlineLinkText({
role="link"
onPress={download ? undefined : onPress}
onLongPress={onLongPress}
- onMouseEnter={onHoverIn}
- onMouseLeave={onHoverOut}
+ onMouseEnter={() => {
+ rest.onMouseEnter?.()
+ onHoverIn()
+ }}
+ onMouseLeave={() => {
+ rest.onMouseLeave?.()
+ onHoverOut()
+ }}
accessibilityRole="link"
href={href}
{...web({
diff --git a/src/components/Post/Embed/StandardSiteEmbed/StandardSiteMetaRow.tsx b/src/components/Post/Embed/StandardSiteEmbed/StandardSiteMetaRow.tsx
new file mode 100644
index 0000000000..4b983f4d6e
--- /dev/null
+++ b/src/components/Post/Embed/StandardSiteEmbed/StandardSiteMetaRow.tsx
@@ -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: (
+
+
+
+ {articleDomain}
+
+
+ ),
+ })
+ }
+
+ if (authorProfile) {
+ items.push({
+ key: 'author',
+ node: (
+
+
+ by{' '}
+ {
+ // this link is nested, yes it's not ideal
+ e.stopPropagation()
+ }}
+ onMouseEnter={onInteractWithin}
+ onMouseLeave={onInteractWithout}>
+ @{authorProfile.handle}
+
+
+
+ ),
+ })
+ }
+
+ if (items.length === 0) return null
+
+ return (
+
+ {items.map((item, i) => (
+
+ {i > 0 && •}
+ {item.node}
+
+ ))}
+
+ )
+}
diff --git a/src/components/Post/Embed/StandardSiteEmbed/StandardSiteThemeProvider.tsx b/src/components/Post/Embed/StandardSiteEmbed/StandardSiteThemeProvider.tsx
new file mode 100644
index 0000000000..00c15c4401
--- /dev/null
+++ b/src/components/Post/Embed/StandardSiteEmbed/StandardSiteThemeProvider.tsx
@@ -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 {children}
+}
diff --git a/src/components/Post/Embed/StandardSiteEmbed/index.tsx b/src/components/Post/Embed/StandardSiteEmbed/index.tsx
new file mode 100644
index 0000000000..e618fa35e2
--- /dev/null
+++ b/src/components/Post/Embed/StandardSiteEmbed/index.tsx
@@ -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
+ 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 (
+
+ )
+ }
+
+ return (
+
+
+ {({hovered: maybeHovered}) => {
+ const hovered = maybeHovered && !interactedWithin
+ return (
+
+ {imageUri ? (
+
+ ) : undefined}
+
+
+
+
+ {view.title}
+
+ {view.description ? (
+
+ {view.description}
+
+ ) : undefined}
+
+ {isStandard && (view.createdAt || view.readingTime) && (
+
+ {view.createdAt && (
+
+ {niceDate(i18n, view.createdAt, 'medium', '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({
+ view,
+ hideSubscribe,
+ onPress,
+ onLongPress,
+ themeColors,
+ style,
+}: {
+ view: AppBskyEmbedExternal.ViewExternal
+ hideSubscribe?: boolean
+ onPress?: () => void
+ onLongPress?: () => void
+ themeColors: ThemeColors
+ style?: StyleProp
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+ const {gtPhone} = useBreakpoints()
+ const {
+ state: interactedWithin,
+ onIn: onInteractWithin,
+ onOut: onInteractWithout,
+ } = useInteractionState()
+
+ if (!view.source) return null
+
+ return (
+
+ {({hovered: maybeHovered}) => {
+ const hovered = maybeHovered && !interactedWithin
+ return (
+
+
+
+
+
+
+ {view.source?.title}
+
+
+
+
+
+ {!hideSubscribe && gtPhone && (
+
+ )}
+
+
+ {view.description && (
+
+
+ {view.description}
+
+
+ )}
+
+ {!hideSubscribe && !gtPhone && (
+
+
+
+ )}
+
+ )
+ }}
+
+ )
+}
+
+export function SubscribeButton({
+ view,
+ onPress,
+ onLongPress,
+ style,
+}: {
+ view: AppBskyEmbedExternal.ViewExternal
+ onPress?: () => void
+ onLongPress?: () => void
+ style?: StyleProp
+}) {
+ 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 (
+
+
+ {highlightedPublisher ? (
+ <>
+
+
+ {/*|*/}
+
+ {cta}
+ >
+ ) : (
+ {cta}
+ )}
+
+
+ )
+}
+
+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.source.title][0] ?? ''}
+
+
+
+ )
+}
+
+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.source?.title}
+
+
+
+
+
+ {!hideSubscribe && (
+
+ )}
+
+ )
+}
diff --git a/src/components/Post/Embed/StandardSiteEmbed/publishers.ts b/src/components/Post/Embed/StandardSiteEmbed/publishers.ts
new file mode 100644
index 0000000000..aba3e28cc2
--- /dev/null
+++ b/src/components/Post/Embed/StandardSiteEmbed/publishers.ts
@@ -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))
+}
diff --git a/src/components/Post/Embed/StandardSiteEmbed/utils.test.ts b/src/components/Post/Embed/StandardSiteEmbed/utils.test.ts
new file mode 100644
index 0000000000..5f5dd23d60
--- /dev/null
+++ b/src/components/Post/Embed/StandardSiteEmbed/utils.test.ts
@@ -0,0 +1,70 @@
+import {type AppBskyEmbedExternal} from '@atproto/api'
+
+import {isStandardSiteEmbed, isStandardSitePublicationEmbed} from './utils'
+
+function makeView(
+ partial: Record,
+): 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()
+ })
+})
diff --git a/src/components/Post/Embed/StandardSiteEmbed/utils.ts b/src/components/Post/Embed/StandardSiteEmbed/utils.ts
new file mode 100644
index 0000000000..2338ca59a3
--- /dev/null
+++ b/src/components/Post/Embed/StandardSiteEmbed/utils.ts
@@ -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',
+ )
+ )
+}
diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx
index 0c3106c1b9..2114904557 100644
--- a/src/components/Post/Embed/index.tsx
+++ b/src/components/Post/Embed/index.tsx
@@ -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 (
+
+
+
+ )
+ }
return (
{
if (data) {
if (data.type === 'external') {
+ if (data.view && isStandardSiteEmbed(data.view.external)) {
+ return (
+
+ )
+ }
return (
void
style?: StyleProp
+ extraAviStyle?: ImageStyle
}
interface EditableUserAvatarProps extends BaseUserAvatarProps {
@@ -224,6 +226,7 @@ let UserAvatar = ({
live,
hideLiveBadge,
noBorder,
+ extraAviStyle,
}: UserAvatarProps): React.ReactNode => {
const t = useTheme()
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
@@ -241,8 +244,9 @@ let UserAvatar = ({
height: size,
borderRadius,
backgroundColor: t.palette.contrast_25,
+ ...extraAviStyle,
}
- }, [finalShape, size, t])
+ }, [finalShape, size, t, extraAviStyle])
const borderStyle = useMemo(() => {
return [