From 21c9e5c38a0f6883b6799d1394cb5d0fbe4b9fe8 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 21 May 2026 13:22:17 -0500 Subject: [PATCH] UI work --- assets/icons/community/leaflet.svg | 1 + assets/icons/community/offprint.svg | 1 + assets/icons/community/pckt-full.svg | 1 + assets/icons/community/pckt.svg | 1 + assets/icons/community/standard-site.svg | 1 + .../PublicationEmbed/MetaRow.tsx | 108 --- .../PublicationEmbed/PublicationFooter.tsx | 88 --- .../PublicationEmbed/__tests__/util.test.ts | 32 - .../ExternalEmbed/PublicationEmbed/index.tsx | 116 --- .../ExternalEmbed/PublicationEmbed/types.ts | 42 - .../ExternalEmbed/PublicationEmbed/util.ts | 10 - .../Post/Embed/StandardSiteEmbed/index.tsx | 717 ++++++++++++++++++ src/components/Post/Embed/index.tsx | 15 +- src/components/icons/community/Leaflet.tsx | 5 + src/components/icons/community/Offprint.tsx | 5 + src/components/icons/community/Pckt.tsx | 9 + .../icons/community/StandardSite.tsx | 5 + src/lib/api/index.ts | 1 + src/lib/api/resolve.ts | 12 +- src/lib/link-meta/link-meta.ts | 8 +- src/lib/strings/time.ts | 6 +- src/view/com/composer/ExternalEmbed.tsx | 32 +- src/view/com/util/UserAvatar.tsx | 5 +- 23 files changed, 787 insertions(+), 434 deletions(-) create mode 100644 assets/icons/community/leaflet.svg create mode 100644 assets/icons/community/offprint.svg create mode 100644 assets/icons/community/pckt-full.svg create mode 100644 assets/icons/community/pckt.svg create mode 100644 assets/icons/community/standard-site.svg delete mode 100644 src/components/Post/Embed/ExternalEmbed/PublicationEmbed/MetaRow.tsx delete mode 100644 src/components/Post/Embed/ExternalEmbed/PublicationEmbed/PublicationFooter.tsx delete mode 100644 src/components/Post/Embed/ExternalEmbed/PublicationEmbed/__tests__/util.test.ts delete mode 100644 src/components/Post/Embed/ExternalEmbed/PublicationEmbed/index.tsx delete mode 100644 src/components/Post/Embed/ExternalEmbed/PublicationEmbed/types.ts delete mode 100644 src/components/Post/Embed/ExternalEmbed/PublicationEmbed/util.ts create mode 100644 src/components/Post/Embed/StandardSiteEmbed/index.tsx create mode 100644 src/components/icons/community/Leaflet.tsx create mode 100644 src/components/icons/community/Offprint.tsx create mode 100644 src/components/icons/community/Pckt.tsx create mode 100644 src/components/icons/community/StandardSite.tsx diff --git a/assets/icons/community/leaflet.svg b/assets/icons/community/leaflet.svg new file mode 100644 index 0000000000..ae7d7bfe3c --- /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..f52160abc0 --- /dev/null +++ b/assets/icons/community/standard-site.svg @@ -0,0 +1 @@ + diff --git a/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/MetaRow.tsx b/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/MetaRow.tsx deleted file mode 100644 index b3ccc5a8b5..0000000000 --- a/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/MetaRow.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import {View} from 'react-native' -import {plural} from '@lingui/core/macro' -import {Trans, useLingui} from '@lingui/react/macro' - -import {atoms as a, useTheme} from '#/alf' -import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock' -import {Leaf_Stroke2_Corner0_Rounded as Leaf} from '#/components/icons/Leaf' -import {Text} from '#/components/Typography' -import {type PublicationViewExternal} from './types' - -export function MetaRow({link}: {link: PublicationViewExternal}) { - const t = useTheme() - const {i18n} = useLingui() - - // Guard against malformed dates (REG/EDGE from sweep): only render when - // `new Date(...)` produces a finite timestamp. - let formattedDate: string | undefined - if (link.createdAt) { - const parsed = new Date(link.createdAt) - if (!Number.isNaN(parsed.getTime())) { - formattedDate = i18n.date(parsed, {dateStyle: 'medium'}) - } - } - - return ( - - - {formattedDate && ( - - {formattedDate} - - )} - {/* - TODO(APP-2160): Enable once the lexicon exposes an aggregate share - count for the external URL. `associatedBskyPost.repostCount` is the - wrong semantics (reshares of a single canonical post, not shares of - the URL). Component exists below for the moment that field lands. - */} - {/* */} - {typeof link.readingTime === 'number' && link.readingTime > 0 && ( - - )} - - {/* - TODO(APP-2160): Enable once the lexicon exposes a host/platform name - on `viewExternalSource` (e.g. "Leaflet"). Component exists below for - the moment that field lands. - */} - {/* */} - - ) -} - -function ReadingTimeChip({minutes}: {minutes: number}) { - const t = useTheme() - return ( - - - - - {plural(minutes, {one: '# min', other: '# min'})} - - - - ) -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function SharesChip({count}: {count: number}) { - const t = useTheme() - return ( - - - - {plural(count, {one: '# share', other: '# shares'})} - - - - ) -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function HostedByChip({name}: {name: string}) { - const t = useTheme() - return ( - - - - - Hosted by {name} - - - - ) -} diff --git a/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/PublicationFooter.tsx b/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/PublicationFooter.tsx deleted file mode 100644 index ad4ce1bf56..0000000000 --- a/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/PublicationFooter.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import {useEffect} from 'react' -import {View} from 'react-native' -import {Trans, useLingui} from '@lingui/react/macro' - -import {toNiceDomain} from '#/lib/strings/url-helpers' -import {logger} from '#/logger' -import {useProfileQuery} from '#/state/queries/profile' -import {UserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, useTheme} from '#/alf' -import {Link} from '#/components/Link' -import {Text} from '#/components/Typography' -import {type PublicationViewExternalSource} from './types' -import {parseDidFromAtUri} from './util' - -export function PublicationFooter({ - source, -}: { - source: PublicationViewExternalSource -}) { - const t = useTheme() - const {t: l} = useLingui() - - const did = parseDidFromAtUri(source.associatedRecord?.uri) - const profileQuery = useProfileQuery({did: did ?? undefined}) - const profileError = profileQuery.error - useEffect(() => { - if (profileError && did) { - // Log once per error transition. React Query handles transient network - // failures via retry; once the error is set we don't want to re-log on - // every render (could burn Sentry quota with a feed of broken DIDs). - logger.error('PublicationEmbed handle resolve failed', { - safeMessage: profileError, - }) - } - }, [profileError, did]) - const handle = did ? profileQuery.data?.handle : undefined - - const name = source.name || (source.uri ? toNiceDomain(source.uri) : '') - - const content = (hovered: boolean) => ( - - - - - {name} - - {handle && ( - - - by @{handle} - - - )} - - - ) - - if (!source.uri) { - // No publication URL to link to; render the row as static content. - return content(false) - } - - return ( - - {({hovered}) => content(hovered)} - - ) -} diff --git a/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/__tests__/util.test.ts b/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/__tests__/util.test.ts deleted file mode 100644 index 7d3e0ae85f..0000000000 --- a/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/__tests__/util.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {parseDidFromAtUri} from '#/components/Post/Embed/ExternalEmbed/PublicationEmbed/util' - -describe('parseDidFromAtUri', () => { - it('extracts a plc DID from a publication at-uri', () => { - expect( - parseDidFromAtUri('at://did:plc:abc123/site.standard.publication/3jx'), - ).toBe('did:plc:abc123') - }) - - it('extracts a web DID', () => { - expect( - parseDidFromAtUri( - 'at://did:web:example.com/site.standard.publication/3jx', - ), - ).toBe('did:web:example.com') - }) - - it('returns undefined for non-at-uri strings', () => { - expect(parseDidFromAtUri('https://example.com')).toBeUndefined() - }) - - it('returns undefined for empty / nullish input', () => { - expect(parseDidFromAtUri('')).toBeUndefined() - expect(parseDidFromAtUri(undefined)).toBeUndefined() - }) - - it('returns undefined when the authority is not a DID', () => { - expect( - parseDidFromAtUri('at://alice.bsky.social/site.standard.publication/3jx'), - ).toBeUndefined() - }) -}) diff --git a/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/index.tsx b/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/index.tsx deleted file mode 100644 index 700dc7edeb..0000000000 --- a/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/index.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import {type StyleProp, View, type ViewStyle} from 'react-native' -import {Image} from 'expo-image' -import {useLingui} from '@lingui/react/macro' - -import {useHaptics} from '#/lib/haptics' -import {shareUrl} from '#/lib/sharing' -import {atoms as a, useTheme} from '#/alf' -import {Divider} from '#/components/Divider' -import {Link} from '#/components/Link' -import {Text} from '#/components/Typography' -import {IS_NATIVE} from '#/env' -import {MetaRow} from './MetaRow' -import {PublicationFooter} from './PublicationFooter' -import { - type PublicationViewExternal, - type PublicationViewExternalSource, -} from './types' - -export function PublicationEmbed({ - link, - source, - onOpen, - style, -}: { - link: PublicationViewExternal - source: PublicationViewExternalSource - onOpen?: () => void - style?: StyleProp -}) { - const t = useTheme() - const {t: l} = useLingui() - const playHaptic = useHaptics() - - const onPress = () => { - playHaptic('Light') - onOpen?.() - } - - const onShareExternal = () => { - if (link.uri && IS_NATIVE) { - playHaptic('Heavy') - shareUrl(link.uri) - } - } - - return ( - - - {({hovered}) => ( - - {link.thumb ? ( - - ) : null} - - - - {link.title || link.uri} - - {link.description ? ( - - {link.description} - - ) : null} - - - - - )} - - - - {/* - Note: `source.theme` (background/foreground/accent/accentForeground - RGB triplets) is exposed by the lexicon but not consumed here. The - current design does not apply per-publication accent colors. Pick up - when product asks for theme accenting. - */} - - ) -} diff --git a/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/types.ts b/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/types.ts deleted file mode 100644 index e22c7a6667..0000000000 --- a/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - type AppBskyEmbedExternal, - type ComAtprotoRepoStrongRef, -} from '@atproto/api' - -/** - * Local extension of `app.bsky.embed.external#viewExternal` that mirrors - * atproto PR #4915. Once @atproto/api ships those fields, delete this file - * and replace usages with `AppBskyEmbedExternal.ViewExternal` directly. - */ -export type PublicationViewExternal = AppBskyEmbedExternal.ViewExternal & { - createdAt?: string - updatedAt?: string - readingTime?: number - source?: PublicationViewExternalSource - associatedRecord?: ComAtprotoRepoStrongRef.Main - associatedBskyPost?: ComAtprotoRepoStrongRef.Main -} - -export interface PublicationViewExternalSource { - $type?: 'app.bsky.embed.external#viewExternalSource' - uri?: string - icon?: string - name?: string - description?: string - theme?: PublicationViewExternalSourceTheme - associatedRecord?: ComAtprotoRepoStrongRef.Main -} - -export interface PublicationViewExternalSourceTheme { - $type?: 'app.bsky.embed.external#viewExternalSourceTheme' - background?: PublicationColorRGB - foreground?: PublicationColorRGB - accent?: PublicationColorRGB - accentForeground?: PublicationColorRGB -} - -export interface PublicationColorRGB { - r: number - g: number - b: number -} diff --git a/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/util.ts b/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/util.ts deleted file mode 100644 index 0fcd2c355c..0000000000 --- a/src/components/Post/Embed/ExternalEmbed/PublicationEmbed/util.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Extract the DID from an at-uri of the form `at://did:://`. - * Returns undefined if the input is falsy, not an at-uri, or the authority is not a DID. - */ -export function parseDidFromAtUri(uri: string | undefined): string | undefined { - if (!uri || !uri.startsWith('at://')) return undefined - const authority = uri.slice('at://'.length).split('/')[0] - if (!authority || !authority.startsWith('did:')) return undefined - return authority -} diff --git a/src/components/Post/Embed/StandardSiteEmbed/index.tsx b/src/components/Post/Embed/StandardSiteEmbed/index.tsx new file mode 100644 index 0000000000..623049fc5e --- /dev/null +++ b/src/components/Post/Embed/StandardSiteEmbed/index.tsx @@ -0,0 +1,717 @@ +import {useCallback, useMemo} from 'react' +import {type StyleProp, View, type ViewStyle} from 'react-native' +import {Image} from 'expo-image' +import {type AppBskyEmbedExternal, AtUri} from '@atproto/api' +import {plural} from '@lingui/core/macro' +import {useLingui} from '@lingui/react/macro' + +import {useHaptics} from '#/lib/haptics' +import {shareUrl} from '#/lib/sharing' +import { + exemptExternalEmbedSources, + parseEmbedPlayerFromUrl, +} from '#/lib/strings/embed-player' +import {niceDate} from '#/lib/strings/time' +import {toNiceDomain} from '#/lib/strings/url-helpers' +import {useExternalEmbedsPrefs} from '#/state/preferences' +import {useProfileQuery} from '#/state/queries/profile' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {ButtonIcon, ButtonText} from '#/components/Button' +import {Divider} from '#/components/Divider' +import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock' +import {Leaflet} from '#/components/icons/community/Leaflet' +import {Offprint} from '#/components/icons/community/Offprint' +import {Pckt} from '#/components/icons/community/Pckt' +import {StandardSite} from '#/components/icons/community/StandardSite' +import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' +import {Link} from '#/components/Link' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {Text} from '#/components/Typography' +import {IS_NATIVE} from '#/env' + +export type ThemeColors = { + accent: string + accentForeground: string +} + +export function isStandardSiteEmbed(view: AppBskyEmbedExternal.ViewExternal) { + return view.associatedRefs?.some(ref => + new AtUri(ref.uri).collection.startsWith('site.standard.'), + ) +} + +export function useStandardSitePublisherConfig( + view: AppBskyEmbedExternal.ViewExternal, +) { + return useMemo(() => { + try { + const u = new URL(view.source?.uri || '') + if (u.host.endsWith('leaflet.pub')) { + return { + name: 'Leaflet', + Icon: Leaflet, + } + } else if (u.host.endsWith('pckt.blog')) { + return { + name: 'pckt', + Icon: Pckt, + } + } else if (u.host.endsWith('offprint.app')) { + return { + name: 'Offprint', + Icon: Offprint, + } + } + return null + } catch (e) { + return null + } + }, [view]) +} + +export const StandardSiteEmbed = ({ + view, + onOpen, + style, + hideSubscribe, +}: { + view: AppBskyEmbedExternal.ViewExternal + onOpen?: () => void + style?: StyleProp + hideSubscribe?: boolean +}) => { + const {t: l, i18n} = useLingui() + const t = useTheme() + const playHaptic = useHaptics() + const externalEmbedPrefs = useExternalEmbedsPrefs() + const niceUrl = toNiceDomain(view.uri) + const imageUri = view.thumb + const embedPlayerParams = useMemo(() => { + const params = parseEmbedPlayerFromUrl(view.uri) + if (!params) return + const canShow = externalEmbedPrefs?.[params.source] !== 'hide' + if (canShow || exemptExternalEmbedSources.has(params.source)) { + return params + } + }, [view.uri, externalEmbedPrefs]) + const hasMedia = Boolean(imageUri || embedPlayerParams) + const isStandard = view.associatedRefs?.some(ref => + new AtUri(ref.uri).collection.startsWith('site.standard.'), + ) + const isStandardPublication = view.associatedRefs?.every( + ref => + new AtUri(ref.uri).collection === 'site.standard.publication' && + new AtUri(ref.uri).collection !== 'site.standard.document', + ) + const themeColors = useMemo(() => { + let accent = t.atoms.text.color + let accentForeground = t.atoms.text_inverted.color + const {accentRGB, accentForegroundRGB} = view.source?.theme || {} + if (accent && accentForeground) { + accent = colorRGBToHex(accentRGB) + accentForeground = colorRGBToHex(accentForegroundRGB) + } + return {accent, accentForeground} + }, [view]) + + const maybeAuthorDid = useMemo(() => { + const publicationUri = view.associatedRefs?.find( + ref => new AtUri(ref.uri).collection === 'site.standard.publication', + )?.uri + if (!publicationUri) return null + return new AtUri(publicationUri)?.did + }, [view]) + + const onPress = useCallback(() => { + playHaptic('Light') + onOpen?.() + }, [playHaptic, onOpen]) + + const onLongPress = useCallback(() => { + if (view.uri && IS_NATIVE) { + playHaptic('Heavy') + shareUrl(view.uri) + } + }, [view.uri, playHaptic]) + + if (isStandardPublication) { + return ( + + ) + } + + return ( + + + {({hovered}) => ( + + {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 && ( + + + + + + {toNiceDomain(view.uri)} + + + + )} + + + )} + + + {view.source && ( + + + + + )} + + ) +} + +export function PublicationCard({ + view, + hideSubscribe, + onPress, + onLongPress, + themeColors, + style, + author, +}: { + view: AppBskyEmbedExternal.ViewExternal + hideSubscribe?: boolean + onPress?: () => void + onLongPress?: () => void + themeColors: ThemeColors + style?: StyleProp + author: {did: string | null | undefined} +}) { + const t = useTheme() + const {t: l} = useLingui() + const {gtPhone} = useBreakpoints() + const profileQuery = useProfileQuery({did: author.did ?? undefined}) + const handle = author.did ? profileQuery.data?.handle : undefined + const highlightedPublisher = useStandardSitePublisherConfig(view) + + if (!view.source) return null + + return ( + + {({hovered}) => ( + + + + <> + + + + {view.source?.title} + + + {[ + { + type: 'domain', + enabled: !highlightedPublisher, + value: toNiceDomain(view.source?.uri || ''), + hasPrev: false, + }, + { + type: 'author', + enabled: true, + value: handle ? l`by @${handle}` : undefined, + hasPrev: + !highlightedPublisher && Boolean(view.source?.uri), + }, + ] + .filter(item => item.enabled && item.value) + .map(item => { + return ( + <> + {item.hasPrev && ( + + • + + )} + + {item.value} + + + ) + })} + + + + + + {!hideSubscribe && ( + + )} + + + {view.description && ( + + + {view.description} + + + )} + + )} + + ) +} + +export function SubscribeButton({ + view, + onPress, + onLongPress, + style, +}: { + view: AppBskyEmbedExternal.ViewExternal + onPress?: () => void + onLongPress?: () => void + themeColors: ThemeColors + style?: StyleProp +}) { + const {t: l} = useLingui() + const highlightedPublisher = useStandardSitePublisherConfig(view) + const cta = highlightedPublisher + ? l`Subscribe on ${highlightedPublisher.name}` + : l`View publication` + + 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 + return view.source?.icon ? ( + + + + + ) : ( + + + + + ) +} + +export function PublicationFooter({ + view, + hideSubscribe, + onPress, + onLongPress, + themeColors, + author, +}: { + view: AppBskyEmbedExternal.ViewExternal + hideSubscribe?: boolean + themeColors: ThemeColors + onPress?: () => void + onLongPress?: () => void + author: {did: string | null | undefined} +}) { + const t = useTheme() + const {t: l} = useLingui() + const {gtPhone} = useBreakpoints() + const profileQuery = useProfileQuery({did: author.did ?? undefined}) + const handle = author.did ? profileQuery.data?.handle : undefined + const highlightedPublisher = useMemo(() => { + try { + const u = new URL(view.source?.uri || '') + if (u.host.endsWith('leaflet.pub')) { + return 'Leaflet' + } else if (u.host.endsWith('pckt.blog')) { + return 'pckt' + } else if (u.host.endsWith('offprint.app')) { + return 'Offprint' + } + return null + } catch (e) { + return null + } + }, [view]) + + if (!view.source) return null + + return ( + + + {({hovered}) => ( + <> + + + + {view.source?.title} + + + {[ + { + type: 'domain', + enabled: !highlightedPublisher, + value: toNiceDomain(view.source?.uri || ''), + hasPrev: false, + }, + { + type: 'author', + enabled: true, + value: handle ? l`by @${handle}` : undefined, + hasPrev: !highlightedPublisher && Boolean(view.source?.uri), + }, + ] + .filter(item => item.enabled && item.value) + .map(item => { + return ( + <> + {item.hasPrev && ( + + • + + )} + + {item.value} + + + ) + })} + + + + )} + + + {!hideSubscribe && ( + + )} + + ) +} + +function colorRGBToHex( + rgb: AppBskyEmbedExternal.ViewExternalSourceTheme['accentRGB'], +): string { + if (!rgb) return '#000000' + const {r, g, b} = rgb + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}` +} diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index 44c25e3fdc..a2eed6a9cc 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -22,6 +22,10 @@ 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 { + isStandardSiteEmbed, + StandardSiteEmbed, +} from '#/components/Post/Embed/StandardSiteEmbed' import {RichText} from '#/components/RichText' import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {SubtleHover} from '#/components/SubtleHover' @@ -32,8 +36,6 @@ import { parseEmbed, } from '#/types/bsky/post' import {ExternalEmbed} from './ExternalEmbed' -import {PublicationEmbed} from './ExternalEmbed/PublicationEmbed' -import {type PublicationViewExternal} from './ExternalEmbed/PublicationEmbed/types' import {ModeratedFeedEmbed} from './FeedEmbed' import {ImageEmbed} from './ImageEmbed' import {ModeratedListEmbed} from './ListEmbed' @@ -97,16 +99,13 @@ function MediaEmbed({ ) } case 'link': { - const external = embed.view.external as PublicationViewExternal - if (external.source) { + if (isStandardSiteEmbed(embed.view.external)) { return ( - diff --git a/src/components/icons/community/Leaflet.tsx b/src/components/icons/community/Leaflet.tsx new file mode 100644 index 0000000000..d988053371 --- /dev/null +++ b/src/components/icons/community/Leaflet.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from '../TEMPLATE' + +export const Leaflet = createSinglePathSVG({ + path: 'M16.86 3.907c.25.027.47.18.582.405.764 1.527 1.559 3.26 1.559 6.251 0 4.493-3.882 8.14-8.14 8.14a5.92 5.92 0 0 1-4.604-2.178c-.741.682-1.133 1.635-1.133 2.735a.74.74 0 0 1-1.48 0c0-1.632.65-3.05 1.82-4.009a5.923 5.923 0 0 1 4.367-8.3c2.124-.41 3.375-.697 4.278-1.087.852-.367 1.409-.835 2.095-1.685a.74.74 0 0 1 .656-.272m-.273 2.053a6 6 0 0 1-1.89 1.263c-1.077.464-2.491.777-4.586 1.181l-.014.003A4.442 4.442 0 0 0 6.74 14.48 6.3 6.3 0 0 1 8 14.095c.805-.16 1.646-.498 2.38-.892.74-.397 1.32-.823 1.623-1.126a.74.74 0 0 1 1.047 1.046c-.438.438-1.153.945-1.97 1.384-.825.442-1.805.843-2.79 1.039h-.002a5 5 0 0 0-.744.207 4.43 4.43 0 0 0 3.314 1.473h.003l.324-.01c3.34-.18 6.336-3.117 6.336-6.651 0-2.067-.413-3.428-.933-4.604', +}) diff --git a/src/components/icons/community/Offprint.tsx b/src/components/icons/community/Offprint.tsx new file mode 100644 index 0000000000..e7fd2fc777 --- /dev/null +++ b/src/components/icons/community/Offprint.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from '../TEMPLATE' + +export const Offprint = createSinglePathSVG({ + path: 'M7.638 11.873c.096.796.45 2.419 1.255 3.787.097.191.354.16.419 0l4.636-8.401a.394.394 0 0 0-.097-.51 3.73 3.73 0 0 0-2.575-.89c-3.703.095-3.735 4.71-3.638 6.014m5.086 6.237c3.606-.095 3.735-4.677 3.606-5.95-.064-.828-.418-2.483-1.255-3.915-.065-.127-.29-.127-.355 0l-4.668 8.465c-.064.191-.032.382.097.51a3.73 3.73 0 0 0 2.575.89m3.928-13.365-.193.35c-.129.223-.065.477.129.573C19.163 7.29 20 10.09 20 11.937c0 2.291-1.931 7.637-7.823 7.637-1.159 0-2.157-.159-3.058-.445a.415.415 0 0 0-.483.159l-.258.509c-.096.19-.322.255-.515.16l-.354-.192a.37.37 0 0 1-.16-.509l.224-.414c.097-.19.033-.413-.128-.509C4.869 16.678 4 13.846 4 12.064c0-2.291 1.77-7.637 7.694-7.637 1.224 0 2.254.159 3.155.445a.46.46 0 0 0 .515-.19l.29-.51a.366.366 0 0 1 .483-.127l.386.19c.161.096.226.319.129.51', +}) diff --git a/src/components/icons/community/Pckt.tsx b/src/components/icons/community/Pckt.tsx new file mode 100644 index 0000000000..6a434be099 --- /dev/null +++ b/src/components/icons/community/Pckt.tsx @@ -0,0 +1,9 @@ +import {createSinglePathSVG} from '../TEMPLATE' + +export const PcktFull = createSinglePathSVG({ + path: 'M4.686 8.752c1.117 0 1.88.517 2.246 1.52q.247.678.249 1.643l-.001.097q-.014.94-.267 1.6l-.074.176c-.394.858-1.123 1.303-2.153 1.303a2.32 2.32 0 0 1-1.408-.455l-.035-.028v1.966a1 1 0 0 1-.008.074c-.029.178-.146.267-.359.267H.854q-.273 0-.335-.216l-.01-.035a1 1 0 0 1-.009-.09v-7.34a.7.7 0 0 1 .02-.149.27.27 0 0 1 .155-.18l.027-.01.005-.002a.5.5 0 0 1 .126-.02h2.066q.04 0 .076.007h.007l.048.013a.27.27 0 0 1 .204.231l.005.028a1 1 0 0 1 .004.07 2 2 0 0 1 .185-.131l.039-.024a2 2 0 0 1 .289-.144q.413-.17.93-.171m5.367.041c.46 0 .958.048 1.655.289l.015.005.021.008.06.021.02.007.018.008.017.007.033.014.024.012.015.008.02.01.003.004.01.005.006.004.01.007.003.003.01.008.005.003.009.008.003.003.009.007.011.012.008.009.004.004.006.008.005.005.005.007.013.023.005.009.004.007.002.003a.4.4 0 0 1 .036.134l.004.05.083 1.389a1 1 0 0 1 0 .083v.01l-.004.03-.007.043-.002.008q0 .006-.004.012v.005l-.005.014-.005.014-.002.003-.005.011-.002.004-.006.01-.002.004-.005.008q0 .003-.003.005l-.003.003-.006.009-.003.004-.018.016-.023.018-.005.003-.01.005h-.002l-.01.006q-.004 0-.008.003l-.007.002-.007.002-.009.003-.007.001-.01.003-.005.001-.013.002H12l-.012.002h-.007l-.013.001h-.048l-.013-.003-.057-.008-.124-.02a9 9 0 0 0-1.272-.112c-.804 0-1.146.265-1.146.857 0 .495.212.857 1.134.857a10 10 0 0 0 1.396-.12c.271-.049.366.06.354.326l-.083 1.388a1 1 0 0 1-.01.093l-.001.008-.003.013-.002.006-.004.012-.018.047-.007.016-.002.002-.006.011-.003.006c-.068.107-.192.153-.358.21a4.5 4.5 0 0 1-1.655.3c-1.24 0-2.135-.377-2.706-.996.306-.61.445-1.365.445-2.219 0-.828-.13-1.562-.418-2.16.574-.6 1.464-.963 2.69-.963m11.419-1.227q.354 0 .354.363v1.073c.386.048.796.116 1.265.21.2.041.327.242.285.446l-.307 1.477a.374.374 0 0 1-.438.293 17 17 0 0 0-.805-.142v.603c0 .724.425.82.71.821.117 0 .33-.024.52-.048.271-.036.39.072.378.338l-.082 1.46c-.012.29-.166.46-.415.52a5.5 5.5 0 0 1-1.24.145c-1.869 0-2.614-.978-2.614-2.982v-.985l-.165.008a.4.4 0 0 1-.262-.085l-.002-.002a.4.4 0 0 1-.04-.039l-.004-.003a.4.4 0 0 1-.05-.071l-.003-.005a.4.4 0 0 1-.023-.052l-.002-.005-.008-.025v-.006a.4.4 0 0 1-.01-.057l-.001-.011v-.006l-.001-.019v-.784c0-.165.07-.322.193-.43.191-.168.493-.435.759-.673.154-.139.296-.268.397-.363l.003-.003.744-.683c.201-.181.438-.278.71-.278zM14.052 7c.249 0 .368.12.368.362v2.52c.375-.199.75-.37 1.143-.512.91-.328 1.852-.47 3.017-.482l-.283.25a1.14 1.14 0 0 0-.385.858v.784q.002.214.083.394c-.294.02-.56.052-.806.096l.01.029c.436 1.35 1.099 3.357 1.099 3.357.082.254-.013.386-.273.386h-2.01a.524.524 0 0 1-.532-.386l-.77-2.338q-.142.09-.293.188v2.174q.002.362-.367.362h-1.665a.95.95 0 0 0 .23-.6l.083-1.39v-.007c.008-.165-.001-.484-.243-.718-.248-.24-.564-.225-.748-.194l-.033.005v-.355l.033.006c.197.035.521.046.77-.208.23-.234.24-.544.233-.713v-.009l-.083-1.388a.93.93 0 0 0-.317-.685 1.3 1.3 0 0 0-.382-.209l-.093-.032-.161-.054V7.362q0-.362.354-.362zM3.919 10.868c-.556 0-.74.423-.74 1.132s.184 1.132.74 1.132.738-.423.738-1.132-.182-1.132-.738-1.132', +}) + +export const Pckt = createSinglePathSVG({ + path: 'M12.781 4c1.543 0 2.853.528 3.752 1.661q.132.167.25.347.537.358.963.891C18.62 8 19 9.54 19 11.355c0 1.444-.241 2.715-.777 3.73q.081.057.156.128c.238.224.376.504.463.752l.006.014.004.015c.112.359.188.88-.106 1.362q-.091.15-.205.26.024.154.013.322c-.042.569-.407.95-.721 1.18-.234.169-.568.355-.986.355a1.3 1.3 0 0 1-.627-.157 1.3 1.3 0 0 1-.63.157c-.4 0-.744-.186-.978-.356l-.01-.008c-.258-.193-.571-.503-.68-.952a2 2 0 0 1-.523-.278l-.01-.008c-.16-.12-.34-.284-.48-.497a5 5 0 0 1-.373-.1v1.687c0 .341-.085.773-.423 1.11-.333.331-.756.412-1.089.412H7.705c-.334 0-.752-.086-1.08-.42a1.46 1.46 0 0 1-.394-.836 1.4 1.4 0 0 1-.819-.402C5.084 18.491 5 18.064 5 17.723V5.72c0-.341.084-.767.412-1.102.328-.334.746-.42 1.08-.42h3.32c.27 0 .596.053.886.245A4.85 4.85 0 0 1 12.781 4m0 .929c-.95 0-1.747.277-2.368.772 0-.376-.194-.574-.601-.574h-3.32q-.582 0-.583.594v12.002q0 .594.583.594h3.32c.407 0 .601-.198.601-.594v-3.189c.63.487 1.41.782 2.321.792.005-.147.037-.294.083-.43.068-.215.17-.425.336-.585a.91.91 0 0 1 .742-.248 1 1 0 0 1 .209-.425c.236-.277.582-.354.906-.354.32 0 .667.078.902.356q.049.059.086.122c.579-.868.879-2.089.879-3.644 0-3.406-1.437-5.19-4.096-5.19m2.229 8.819c-.532 0-.744.259-.68.784l.037.194-.175-.092a.9.9 0 0 0-.394-.111c-.266 0-.44.167-.55.526-.166.488.018.774.541.866l.192.027-.137.158c-.376.368-.348.7.083 1.023.165.12.32.183.449.184.211 0 .395-.147.542-.433l.092-.176.092.176c.146.286.32.433.532.433.146 0 .294-.064.459-.184.431-.313.449-.655.082-1.033l-.128-.148.183-.027c.533-.092.697-.378.541-.876-.119-.34-.293-.507-.54-.507a.96.96 0 0 0-.405.11l-.183.084.027-.194q.124-.786-.66-.784m-3.49-5.349c.913 0 1.213.696 1.213 1.858s-.3 1.856-1.213 1.856c-.912 0-1.212-.694-1.212-1.856s.3-1.857 1.213-1.858', +}) diff --git a/src/components/icons/community/StandardSite.tsx b/src/components/icons/community/StandardSite.tsx new file mode 100644 index 0000000000..c283cc39f8 --- /dev/null +++ b/src/components/icons/community/StandardSite.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from '../TEMPLATE' + +export const StandardSite = createSinglePathSVG({ + path: 'M11.862 3.042a.25.25 0 0 1 .276 0c.098.066.15.712.255 2.004l.036.45h2.484a3.56 3.56 0 0 1 3.559 3.559v.613h-1.228v-.613a2.33 2.33 0 0 0-2.331-2.332H12.53c.11 1.243.22 1.965.545 2.557a3.93 3.93 0 0 0 1.519 1.537c.727.41 1.66.5 3.526.682l1.025.1c1.165.114 1.748.17 1.813.268a.24.24 0 0 1-.002.273c-.066.097-.65.144-1.817.24l-.668.054v2.512a3.56 3.56 0 0 1-3.56 3.559h-2.486l-.033.42c-.104 1.31-.157 1.966-.255 2.033a.25.25 0 0 1-.276 0c-.098-.066-.15-.722-.255-2.033l-.033-.42H9.022a3.56 3.56 0 0 1-3.56-3.56v-.613H6.69v.613a2.33 2.33 0 0 0 2.332 2.332h2.452c-.112-1.29-.223-2.034-.557-2.639a3.93 3.93 0 0 0-1.548-1.544c-.74-.406-1.69-.484-3.59-.64l-.92-.074c-1.166-.096-1.75-.143-1.816-.24a.24.24 0 0 1-.002-.273c.065-.097.647-.154 1.813-.268l.609-.06V9.056a3.56 3.56 0 0 1 3.559-3.56h2.549l.036-.449c.105-1.292.157-1.938.255-2.004m5.382 9.495c-1.272.114-2.011.227-2.613.557a3.93 3.93 0 0 0-1.548 1.544c-.334.605-.445 1.349-.557 2.64h2.387a2.33 2.33 0 0 0 2.331-2.332zM9.022 6.723A2.33 2.33 0 0 0 6.69 9.055v2.364c1.342-.138 2.102-.257 2.715-.602a3.93 3.93 0 0 0 1.52-1.537c.323-.592.434-1.314.544-2.557z', +}) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 0e6200c32b..1edc8c8016 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -442,6 +442,7 @@ async function resolveMedia( title: resolvedLink.title, description: resolvedLink.description, thumb: blob, + associatedRefs: resolvedLink.associatedRefs, }, } } diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index 3e97dbf1fd..d0600b2eb8 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -26,7 +26,6 @@ import { } from '#/lib/strings/url-helpers' import {type ComposerImage} from '#/state/gallery' import {createComposerImage} from '#/state/gallery' -import {type PublicationViewExternalSource} from '#/components/Post/Embed/ExternalEmbed/PublicationEmbed/types' import {type Gif} from '#/features/gifPicker/types' import {createGIFDescription} from '../gif-alt-text' @@ -40,12 +39,8 @@ type ResolvedExternalLink = { * The AT-URI of the Atmosphere record representing this external content, if * it exists. Example: a site.standard.document record. */ - associatedRecord?: LinkMeta['associatedRecord'] - // APP-2160 / APP-2155: populated by Cardyb when the URL resolves to a - // standard.site publication. Until APP-2155 ships these stay undefined. - source?: PublicationViewExternalSource - createdAt?: string - readingTime?: number + associatedRefs?: LinkMeta['associatedRefs'] + view?: LinkMeta['view'] } type ResolvedPostRecord = { @@ -251,7 +246,8 @@ async function resolveExternal( title: result.title ?? '', description: result.description ?? '', thumb: result.image ? await imageToThumb(result.image) : undefined, - associatedRecord: result.associatedRecord, + associatedRefs: result.associatedRefs, + view: result.view, } } diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts index 37292f5293..e72bab9f01 100644 --- a/src/lib/link-meta/link-meta.ts +++ b/src/lib/link-meta/link-meta.ts @@ -1,4 +1,4 @@ -import {type BskyAgent} from '@atproto/api' +import {type AppBskyEmbedExternal, type BskyAgent} from '@atproto/api' import {LINK_META_PROXY} from '#/lib/constants' import {getGiphyMetaUri} from '#/lib/strings/embed-player' @@ -26,7 +26,8 @@ export interface LinkMeta { * The AT-URI of the Atmosphere record representing this external content, if * it exists. Example: a site.standard.document record. */ - associatedRecord?: string + associatedRefs?: AppBskyEmbedExternal.External['associatedRefs'] + view?: AppBskyEmbedExternal.View } export async function getLinkMeta( @@ -94,7 +95,8 @@ export async function getLinkMeta( meta.description = body.description meta.image = body.image meta.title = body.title - meta.associatedRecord = body.associated_record + meta.associatedRefs = body.associated_refs + meta.view = body.view || body.external_view if (shouldFollowRedirect) { meta.url = body.url } diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts index 44b3f3ee8a..84f2969c8c 100644 --- a/src/lib/strings/time.ts +++ b/src/lib/strings/time.ts @@ -5,21 +5,23 @@ export function niceDate( i18n: I18n, date: number | string | Date, dateStyle: 'short' | 'medium' | 'long' | 'full' | 'dot separated' = 'long', + timeStyle: 'short' | 'medium' | 'long' | 'full' | 'none' = 'short', ) { + const ts = timeStyle === 'none' ? undefined : timeStyle const d = new Date(date) if (dateStyle === 'dot separated') { return i18n._( msg({ context: 'date and time formatted like this: [time] · [date]', - message: `${i18n.date(d, {timeStyle: 'short'})} · ${i18n.date(d, {dateStyle: 'medium'})}`, + message: `${i18n.date(d, {timeStyle: ts})} · ${i18n.date(d, {dateStyle: 'medium'})}`, }), ) } return i18n.date(d, { dateStyle, - timeStyle: 'short', + timeStyle: ts, }) } diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 23f683b46f..612f08d6bf 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -10,10 +10,12 @@ import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn import {atoms as a, useTheme} from '#/alf' import {Loader} from '#/components/Loader' import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed' -import {PublicationEmbed} from '#/components/Post/Embed/ExternalEmbed/PublicationEmbed' -import {type PublicationViewExternal} from '#/components/Post/Embed/ExternalEmbed/PublicationEmbed/types' import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed' import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed' +import { + isStandardSiteEmbed, + StandardSiteEmbed, +} from '#/components/Post/Embed/StandardSiteEmbed' import {Embed as StarterPackEmbed} from '#/components/StarterPack/StarterPackCard' import {Text} from '#/components/Typography' import {type Gif} from '#/features/gifPicker/types' @@ -88,21 +90,19 @@ export const ExternalEmbedLink = ({ const linkComponent = useMemo(() => { if (data) { if (data.type === 'external') { - if (data.source) { - const publicationLink: PublicationViewExternal = { - uri, - title: data.title || uri, - description: data.description, - thumb: data.thumb?.source.path, - createdAt: data.createdAt, - readingTime: data.readingTime, - source: data.source, - associatedRecord: data.associatedRecord - ? {uri: data.associatedRecord, cid: ''} - : undefined, - } + if (data.view && isStandardSiteEmbed(data.view.external)) { return ( - + ) } return ( diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index a2eba43e15..d3d090c91e 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -76,6 +76,7 @@ interface UserAvatarProps extends BaseUserAvatarProps { noBorder?: boolean onLoad?: () => void style?: StyleProp + extraAviStyle?: ViewStyle } interface EditableUserAvatarProps extends BaseUserAvatarProps { @@ -224,6 +225,7 @@ let UserAvatar = ({ live, hideLiveBadge, noBorder, + extraAviStyle, }: UserAvatarProps): React.ReactNode => { const t = useTheme() const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') @@ -241,8 +243,9 @@ let UserAvatar = ({ height: size, borderRadius, backgroundColor: t.palette.contrast_25, + ...extraAviStyle, } - }, [finalShape, size, t]) + }, [finalShape, size, t, extraAviStyle]) const borderStyle = useMemo(() => { return [