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 [