[APP-2160] Build new standard.site link card UI (#10468)

Co-authored-by: vineyardbovines <spencer@thepope.dev>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Spence Pope
2026-05-22 18:36:06 -04:00
committed by GitHub
parent 6159cd7777
commit 5db37729bb
28 changed files with 1265 additions and 38 deletions
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M16.091 3.001c.385-.01 1.07.04 1.51.646q.122.17.186.334c.317-.029.759-.066 1.17-.032.395.034 1.195.163 1.661.9.411.65.308 1.316.107 1.768a3.3 3.3 0 0 1-.416.667c.142.124.302.293.428.507.302.514.316 1.083.182 1.62-.236.944-1.025 1.356-1.504 1.54-.493.19-1.07.294-1.502.371l-.05.009-.1.017c.106.368.084.759-.039 1.13-.34 1.025-1.114 1.513-1.85 1.718-.632.176-1.337.176-1.839.175h-.18c.126.508-.018 1.009-.158 1.348-.52 1.25-1.696 1.717-2.647 1.915s-2.052.204-2.966.21h-.063c-.5.002-.945.005-1.334.027-.345.666-.63 1.407-.962 2.271v.003l-.27.701c-.37.943-1.413 1.4-2.33 1.02-.918-.378-1.364-1.45-.996-2.392.473-1.207.962-2.049 1.615-3.342.175-.348.322-.628.452-.889 0-.786.057-1.341.17-2.045.19-1.183.35-1.639.585-2.718.215-.746.532-1.747 1.03-2.299.285-.317.727-.78 1.392-.812.404-.02.656.315.869.457.618-.86 1.143-2.005 1.82-2.894.542-.713 1.125-1.336 2.067-1.188.485.077 1.176.452 1.176 1.188.18-.205.729-.995.96-1.188.44-.367 1.048-.721 1.826-.743m-.347.953c-.828.023-1.412.747-1.902 1.354-.355.44-.66.82-.975.828-.308.008-.285-.204-.256-.473.042-.383.095-.88-.78-1.02-.726-.114-1.492 1.266-2.157 2.464-.455.82-.864 1.555-1.18 1.667-.482.172-.705-.03-.896-.202-.23-.208-.413-.373-.947.22-.563.623-.947 2.328-1.319 4.186-.047.237.283.39.434.203.856-1.057 2.248-2.51 3.908-3.585 1.526-.988 2.998-1.97 4.664-2.464.245-.073.315.21.08.312-1.878.82-3.491 1.903-5.181 3.348-1.9 1.589-3.696 3.703-4.607 5.427a22 22 0 0 0-1.511 3.127.6.6 0 0 0 .32.771.573.573 0 0 0 .751-.329q.134-.345.262-.68c.39-1.02.756-1.976 1.271-2.845a.21.21 0 0 1 .154-.101c.56-.069 1.227-.073 1.917-.077 1.881-.01 3.936-.023 4.487-1.348.287-.692-.06-.792-.373-.881-.255-.074-.489-.141-.335-.52.268-.655 1.138-.655 2.061-.655 1.075 0 2.22 0 2.563-1.036.147-.443-.115-.618-.347-.772-.194-.129-.366-.244-.262-.492.167-.394.89-.525 1.658-.663.917-.165 1.895-.34 2.057-.99.154-.614-.133-.812-.429-1.017-.18-.126-.364-.255-.454-.481-.11-.279.13-.576.38-.885.296-.365.604-.746.362-1.13-.276-.435-1.096-.358-1.775-.295-.424.04-.793.073-.94-.025-.148-.1-.142-.268-.136-.436.009-.262.017-.521-.566-.505"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="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"/></svg>

After

Width:  |  Height:  |  Size: 881 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="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"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="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"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11.846 2.047a.275.275 0 0 1 .308 0c.108.073.166.791.283 2.227l.04.499h2.76a3.955 3.955 0 0 1 3.954 3.954v.682h-1.364v-.682a2.59 2.59 0 0 0-2.59-2.59H12.59c.123 1.38.245 2.183.605 2.84a4.37 4.37 0 0 0 1.688 1.708c.808.455 1.845.556 3.918.759l1.14.11c1.294.127 1.942.19 2.014.298a.27.27 0 0 1-.003.304c-.073.107-.722.16-2.018.266l-.743.06v2.79a3.954 3.954 0 0 1-3.955 3.955h-2.762l-.037.467c-.116 1.457-.174 2.185-.283 2.259a.275.275 0 0 1-.308 0c-.108-.073-.166-.802-.282-2.259l-.038-.467H8.691a3.955 3.955 0 0 1-3.955-3.954v-.682H6.1v.682a2.59 2.59 0 0 0 2.59 2.59h2.725c-.124-1.433-.247-2.26-.618-2.932a4.37 4.37 0 0 0-1.72-1.715c-.823-.452-1.878-.538-3.988-.71l-1.023-.084c-1.296-.106-1.945-.16-2.018-.266a.27.27 0 0 1-.003-.304c.072-.108.72-.171 2.015-.297l.676-.066V8.727a3.955 3.955 0 0 1 3.955-3.954h2.832l.04-.5c.117-1.435.175-2.153.283-2.226m5.981 10.55c-1.414.126-2.235.252-2.903.619a4.37 4.37 0 0 0-1.72 1.715c-.372.672-.495 1.499-.62 2.933h2.652a2.59 2.59 0 0 0 2.591-2.591zm-9.136-6.46A2.59 2.59 0 0 0 6.1 8.726v2.627c1.492-.153 2.336-.285 3.017-.669a4.37 4.37 0 0 0 1.688-1.707c.36-.658.483-1.46.605-2.842z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1 -1
View File
@@ -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",
+5 -5
View File
@@ -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
+32 -22
View File
@@ -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<typeof themes>
}>) {
const [fontScale, setFontScale] = useState<Alf['fonts']['scale']>(() =>
getFontScale(),
)
@@ -90,11 +96,15 @@ export function ThemeProvider({
[setFontFamily],
)
const value = useMemo<Alf>(
() => ({
themes,
const value = useMemo<Alf>(() => {
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 <Context.Provider value={value}>{children}</Context.Provider>
}
+94
View File
@@ -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')
})
})
+130
View File
@@ -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))
}
+10 -2
View File
@@ -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({
@@ -0,0 +1,110 @@
import {Fragment, type ReactNode} from 'react'
import {View} from 'react-native'
import {type AppBskyEmbedExternal, AtUri} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {makeProfileLink} from '#/lib/routes/links'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf'
import {StandardSite} from '#/components/icons/community/StandardSite'
import {InlineLinkText} from '#/components/Link'
import {
matchStandardSitePublisher,
matchStandardSitePublisherByUri,
} from '#/components/Post/Embed/StandardSiteEmbed/publishers'
import {
isStandardSiteDocumentUri,
isStandardSitePublicationUri,
} from '#/components/Post/Embed/StandardSiteEmbed/utils'
import {Text} from '#/components/Typography'
export function StandardSiteMetaRow({
type = 'document',
view,
onInteractWithin,
onInteractWithout,
}: {
type?: 'document' | 'publication'
view: AppBskyEmbedExternal.ViewExternal
onInteractWithin: () => void
onInteractWithout: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const highlightedPublisher = !!matchStandardSitePublisher(view)
const didsFromRecords =
view.associatedRefs
?.filter(
type === 'document'
? isStandardSiteDocumentUri
: isStandardSitePublicationUri,
)
.map(ref => new AtUri(ref.uri).host) || []
// atm should only be one docment
const authorDid = didsFromRecords.at(0)
const authorProfile = authorDid
? view.associatedProfiles?.find(p => p.did === authorDid)
: undefined
const articleDomain = toNiceDomain(view.uri)
const articlePublisher = matchStandardSitePublisherByUri(view.uri)
const DomainIcon = articlePublisher?.Icon ?? StandardSite
const metaTextStyle = [
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]
const items: {key: string; node: ReactNode}[] = []
if (!highlightedPublisher) {
items.push({
key: 'domain',
node: (
<View style={[a.flex_row, a.align_center]}>
<DomainIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
<Text numberOfLines={1} style={metaTextStyle}>
{articleDomain}
</Text>
</View>
),
})
}
if (authorProfile) {
items.push({
key: 'author',
node: (
<Text numberOfLines={1} style={metaTextStyle}>
<Trans>
by{' '}
<InlineLinkText
label={l`View @${authorProfile.handle}'s profile`}
to={makeProfileLink(authorProfile)}
style={metaTextStyle}
onPress={e => {
// this link is nested, yes it's not ideal
e.stopPropagation()
}}
onMouseEnter={onInteractWithin}
onMouseLeave={onInteractWithout}>
@{authorProfile.handle}
</InlineLinkText>
</Trans>
</Text>
),
})
}
if (items.length === 0) return null
return (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
{items.map((item, i) => (
<Fragment key={item.key}>
{i > 0 && <Text style={metaTextStyle}></Text>}
{item.node}
</Fragment>
))}
</View>
)
}
@@ -0,0 +1,67 @@
import {type AppBskyEmbedExternal} from '@atproto/api'
import {Context, useAlf, utils} from '#/alf'
/**
* Overrides only the values needed for `secondary_inverted` buttons atm.
*
* Renders the alf Context directly (rather than nesting a ThemeProvider) so
* that font-scale and other parent state stay live in the subtree.
*/
export function StandardSiteThemeProvider({
view,
children,
}: {
view: AppBskyEmbedExternal.ViewExternal
children: React.ReactNode
}) {
const alf = useAlf()
const {accentRGB, accentForegroundRGB} = view.source?.theme || {}
if (!accentRGB || !accentForegroundRGB) return children
const accent = utils.rgbToHex(accentRGB.r, accentRGB.g, accentRGB.b)
const accentForeground = utils.rgbToHex(
accentForegroundRGB.r,
accentForegroundRGB.g,
accentForegroundRGB.b,
)
const atomsOverride = {
text_inverted: {color: accentForeground},
}
const paletteOverride = {
contrast_975: utils.darken(accent, 5), // hover
contrast_900: accent, // bg
contrast_600: utils.lighten(accent, 5), // disabled bg
contrast_300: accentForeground, // disabled text
}
const themes = {
...alf.themes,
lightPalette: {...alf.themes.lightPalette, ...paletteOverride},
darkPalette: {...alf.themes.darkPalette, ...paletteOverride},
dimPalette: {...alf.themes.dimPalette, ...paletteOverride},
light: {
...alf.themes.light,
atoms: {...alf.themes.light.atoms, ...atomsOverride},
palette: {...alf.themes.light.palette, ...paletteOverride},
},
dark: {
...alf.themes.dark,
atoms: {...alf.themes.dark.atoms, ...atomsOverride},
palette: {...alf.themes.dark.palette, ...paletteOverride},
},
dim: {
...alf.themes.dim,
atoms: {...alf.themes.dim.atoms, ...atomsOverride},
palette: {...alf.themes.dim.palette, ...paletteOverride},
},
}
const value = {
...alf,
themes,
theme: themes[alf.themeName],
}
return <Context.Provider value={value}>{children}</Context.Provider>
}
@@ -0,0 +1,579 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {type AppBskyEmbedExternal, AtUri} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {useHaptics} from '#/lib/haptics'
import {shareUrl} from '#/lib/sharing'
import {niceDate} from '#/lib/strings/time'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, utils} from '#/alf'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
import {Link} from '#/components/Link'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {matchStandardSitePublisher} from '#/components/Post/Embed/StandardSiteEmbed/publishers'
import {StandardSiteMetaRow} from '#/components/Post/Embed/StandardSiteEmbed/StandardSiteMetaRow'
import {StandardSiteThemeProvider} from '#/components/Post/Embed/StandardSiteEmbed/StandardSiteThemeProvider'
import {isStandardSitePublicationEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
export type ThemeColors = {
custom: boolean
accent: string
accentForeground: string
}
const PUBLICATION_AVATAR_STYLE = {
borderRadius: a.rounded_sm.borderRadius,
}
export const StandardSiteEmbed = ({
view,
onOpen,
style,
hideSubscribe,
}: {
view: AppBskyEmbedExternal.ViewExternal
onOpen?: () => void
style?: StyleProp<ViewStyle>
hideSubscribe?: boolean
}) => {
const {t: l, i18n} = useLingui()
const t = useTheme()
const playHaptic = useHaptics()
const niceUrl = toNiceDomain(view.uri)
const imageUri = view.thumb
const hasMedia = Boolean(imageUri)
const isStandard = view.associatedRefs?.some(ref =>
new AtUri(ref.uri).collection.startsWith('site.standard.'),
)
const isStandardPublication = isStandardSitePublicationEmbed(view)
let themeColors: ThemeColors = {
custom: false,
accent: t.atoms.text.color,
accentForeground: t.atoms.text_inverted.color,
}
const {accentRGB, accentForegroundRGB} = view.source?.theme || {}
if (accentRGB && accentForegroundRGB) {
themeColors = {
custom: true,
accent: utils.rgbToHex(accentRGB.r, accentRGB.g, accentRGB.b),
accentForeground: utils.rgbToHex(
accentForegroundRGB.r,
accentForegroundRGB.g,
accentForegroundRGB.b,
),
}
}
const {
state: interactedWithin,
onIn: onInteractWithin,
onOut: onInteractWithout,
} = useInteractionState()
const onPress = () => {
playHaptic('Light')
onOpen?.()
}
const onLongPress = () => {
if (view.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.uri)
}
}
if (isStandardPublication) {
return (
<PublicationCard
hideSubscribe={hideSubscribe}
view={view}
onPress={onPress}
onLongPress={onLongPress}
style={style}
themeColors={themeColors}
/>
)
}
return (
<View
style={[
a.flex_col,
a.rounded_lg,
a.overflow_hidden,
a.w_full,
a.border,
t.atoms.border_contrast_low,
style,
]}>
<Link
shouldProxy
to={view.uri}
label={view.title || l`Open link to ${niceUrl}`}
onPress={onPress}
onLongPress={onLongPress}>
{({hovered: maybeHovered}) => {
const hovered = maybeHovered && !interactedWithin
return (
<View style={[a.w_full]}>
{imageUri ? (
<Image
style={[a.aspect_card]}
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
loading="lazy"
/>
) : undefined}
<View
style={[
a.flex_1,
a.pt_sm,
t.atoms.border_contrast_low,
hasMedia && a.border_t,
{gap: 3},
isStandard && a.pt_md,
]}>
<View
style={[
a.pb_xs,
a.px_md,
{gap: 3},
isStandard && [{gap: 5}, a.pb_sm],
]}>
<Text
emoji
numberOfLines={3}
style={[
a.text_md,
a.font_semi_bold,
a.leading_snug,
isStandard && [
a.text_lg,
a.font_bold,
hovered && a.underline,
],
]}>
{view.title}
</Text>
{view.description ? (
<Text
emoji
numberOfLines={view.thumb ? 2 : 4}
style={[a.text_sm, a.leading_snug]}>
{view.description}
</Text>
) : undefined}
{isStandard && (view.createdAt || view.readingTime) && (
<View
style={[
a.flex_row,
a.align_center,
a.gap_md,
{paddingTop: 2},
]}>
{view.createdAt && (
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_high,
]}>
{niceDate(i18n, view.createdAt, 'medium', 'none')}
</Text>
)}
{view.readingTime && (
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<Clock size="xs" style={t.atoms.text_contrast_high} />
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_high,
]}>
{l({
message: plural(view.readingTime, {
one: '#m',
other: '#m',
}),
comment: `How long it takes to read an article, in minutes. Displayed in a short form, e.g. "5m" for 5 minutes.`,
})}
</Text>
</View>
)}
</View>
)}
</View>
{!view.source && (
<View style={[a.px_md]}>
<Divider />
<View style={[a.py_sm]}>
<StandardSiteMetaRow
view={view}
onInteractWithin={onInteractWithin}
onInteractWithout={onInteractWithout}
/>
</View>
</View>
)}
</View>
</View>
)
}}
</Link>
{view.source && (
<>
<View style={[a.px_md]}>
<Divider />
</View>
<PublicationFooter
view={view}
onPress={onPress}
onLongPress={onLongPress}
themeColors={themeColors}
hideSubscribe={hideSubscribe}
/>
</>
)}
</View>
)
}
export function PublicationCard({
view,
hideSubscribe,
onPress,
onLongPress,
themeColors,
style,
}: {
view: AppBskyEmbedExternal.ViewExternal
hideSubscribe?: boolean
onPress?: () => void
onLongPress?: () => void
themeColors: ThemeColors
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
const {t: l} = useLingui()
const {gtPhone} = useBreakpoints()
const {
state: interactedWithin,
onIn: onInteractWithin,
onOut: onInteractWithout,
} = useInteractionState()
if (!view.source) return null
return (
<Link
shouldProxy
to={view.source.uri}
label={l`View publication`}
onPress={onPress}
onLongPress={onLongPress}>
{({hovered: maybeHovered}) => {
const hovered = maybeHovered && !interactedWithin
return (
<View
style={[
a.flex_col,
a.rounded_lg,
a.overflow_hidden,
a.w_full,
a.border,
a.p_md,
t.atoms.border_contrast_low,
style,
]}>
<View
style={[
a.flex_1,
a.align_center,
a.justify_between,
a.gap_md,
gtPhone && [a.flex_row, a.gap_sm],
]}
testID="publication-embed-footer">
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_sm,
gtPhone && a.flex_1,
]}>
<PublicationIcon
view={view}
size={40}
hovered={hovered}
themeColors={themeColors}
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
numberOfLines={1}
style={[
a.text_md,
a.font_semi_bold,
t.atoms.text,
hovered && a.underline,
]}>
{view.source?.title}
</Text>
<StandardSiteMetaRow
type="publication"
view={view}
onInteractWithin={onInteractWithin}
onInteractWithout={onInteractWithout}
/>
</View>
</View>
{!hideSubscribe && gtPhone && (
<SubscribeButton
view={view}
style={[!gtPhone && [a.w_full, a.justify_center]]}
onPress={onPress}
onLongPress={onLongPress}
/>
)}
</View>
{view.description && (
<View style={[a.pt_sm]}>
<Text style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
{view.description}
</Text>
</View>
)}
{!hideSubscribe && !gtPhone && (
<View style={[view.description && a.pt_sm]}>
<SubscribeButton
view={view}
style={[!gtPhone && [a.w_full, a.justify_center]]}
onPress={onPress}
onLongPress={onLongPress}
/>
</View>
)}
</View>
)
}}
</Link>
)
}
export function SubscribeButton({
view,
onPress,
onLongPress,
style,
}: {
view: AppBskyEmbedExternal.ViewExternal
onPress?: () => void
onLongPress?: () => void
style?: StyleProp<ViewStyle>
}) {
const {t: l} = useLingui()
const highlightedPublisher = matchStandardSitePublisher(view)
const cta = highlightedPublisher
? l`Subscribe on ${highlightedPublisher.name}`
: l`View publication`
if (!view.source) return null
return (
<StandardSiteThemeProvider view={view}>
<Link
shouldProxy
to={view.source.uri}
label={cta}
size="small"
color="secondary_inverted"
style={[style, a.gap_sm]}
onPress={onPress}
onLongPress={onLongPress}>
{highlightedPublisher ? (
<>
<View style={[a.flex_row, a.align_center, {gap: 7}]}>
<ButtonIcon icon={highlightedPublisher.Icon} size="lg" />
{/*<ButtonText>|</ButtonText>*/}
</View>
<ButtonText>{cta}</ButtonText>
</>
) : (
<ButtonText>{cta}</ButtonText>
)}
</Link>
</StandardSiteThemeProvider>
)
}
function PublicationIcon({
view,
size,
hovered,
themeColors,
}: {
view: AppBskyEmbedExternal.ViewExternal
size: number
hovered?: boolean
themeColors: ThemeColors
}) {
const opacity = hovered ? 0.6 : 0.2
if (!view.source) return null
return view.source?.icon ? (
<View>
<UserAvatar
noBorder
type="labeler"
size={size}
avatar={view.source.icon}
extraAviStyle={PUBLICATION_AVATAR_STYLE}
/>
<MediaInsetBorder
style={[
a.rounded_sm,
{
borderColor: themeColors.accentForeground,
opacity,
},
]}
/>
</View>
) : (
<View
style={[
a.align_center,
a.justify_center,
a.rounded_sm,
{
width: size,
height: size,
backgroundColor: themeColors.accent,
},
]}>
<Text
emoji
style={[a.text_xl, a.font_bold, {color: themeColors.accentForeground}]}>
{[...view.source.title][0] ?? ''}
</Text>
<MediaInsetBorder
style={[
a.rounded_sm,
{
borderColor: themeColors.accentForeground,
opacity,
},
]}
/>
</View>
)
}
export function PublicationFooter({
view,
hideSubscribe,
onPress,
onLongPress,
themeColors,
}: {
view: AppBskyEmbedExternal.ViewExternal
hideSubscribe?: boolean
themeColors: ThemeColors
onPress?: () => void
onLongPress?: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const {gtPhone} = useBreakpoints()
const {
state: maybeHovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {
state: interactedWithin,
onIn: onInteractWithin,
onOut: onInteractWithout,
} = useInteractionState()
const hovered = maybeHovered && !interactedWithin
if (!view.source) return null
return (
<View
style={[
a.flex_1,
a.align_center,
a.justify_between,
a.p_md,
a.gap_md,
gtPhone && [a.flex_row, a.gap_sm],
]}
testID="publication-embed-footer"
// @ts-ignore it's Fine™
onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}>
<Link
shouldProxy
to={view.source.uri}
label={l`View publication`}
onPress={onPress}
onLongPress={onLongPress}
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_sm,
gtPhone && a.flex_1,
]}>
<PublicationIcon
view={view}
size={32}
hovered={hovered}
themeColors={themeColors}
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
numberOfLines={1}
style={[
a.text_sm,
a.font_medium,
t.atoms.text,
hovered && a.underline,
]}>
{view.source?.title}
</Text>
<StandardSiteMetaRow
type="publication"
view={view}
onInteractWithin={onInteractWithin}
onInteractWithout={onInteractWithout}
/>
</View>
</Link>
{!hideSubscribe && (
<SubscribeButton
view={view}
style={[!gtPhone && [a.w_full, a.justify_center]]}
onPress={onPress}
onLongPress={onLongPress}
/>
)}
</View>
)
}
@@ -0,0 +1,52 @@
import {type AppBskyEmbedExternal} from '@atproto/api'
import {Leaflet} from '#/components/icons/community/Leaflet'
import {Offprint} from '#/components/icons/community/Offprint'
import {Pckt} from '#/components/icons/community/Pckt'
export type StandardSitePublisher = {
host: string
name: string
Icon: typeof Leaflet
}
const STANDARD_SITE_PUBLISHERS: StandardSitePublisher[] = [
{host: 'leaflet.pub', name: 'Leaflet', Icon: Leaflet},
{host: 'pckt.blog', name: 'pckt', Icon: Pckt},
{host: 'offprint.app', name: 'Offprint', Icon: Offprint},
]
function hostFromUri(uri: string | undefined): string | null {
try {
return new URL(uri || '').host
} catch {
return null
}
}
export function getStandardSitePublisherHost(
view: AppBskyEmbedExternal.ViewExternal,
): string | null {
return hostFromUri(view.source?.uri)
}
export function hostMatches(host: string, target: string): boolean {
return host === target || host.endsWith('.' + target)
}
function matchByHost(host: string | null): StandardSitePublisher | null {
if (!host) return null
return STANDARD_SITE_PUBLISHERS.find(p => hostMatches(host, p.host)) ?? null
}
export function matchStandardSitePublisher(
view: AppBskyEmbedExternal.ViewExternal,
): StandardSitePublisher | null {
return matchByHost(getStandardSitePublisherHost(view))
}
export function matchStandardSitePublisherByUri(
uri: string | undefined,
): StandardSitePublisher | null {
return matchByHost(hostFromUri(uri))
}
@@ -0,0 +1,70 @@
import {type AppBskyEmbedExternal} from '@atproto/api'
import {isStandardSiteEmbed, isStandardSitePublicationEmbed} from './utils'
function makeView(
partial: Record<string, unknown>,
): AppBskyEmbedExternal.ViewExternal {
return {
uri: 'https://example.com/post',
title: 'title',
description: 'description',
...partial,
}
}
describe('isStandardSiteEmbed', () => {
it('returns true when any associated ref is in the site.standard.* namespace', () => {
const view = makeView({
associatedRefs: [{uri: 'at://did:plc:abc/site.standard.publication/foo'}],
})
expect(isStandardSiteEmbed(view)).toBe(true)
})
it('returns false when no associated refs are in the site.standard.* namespace', () => {
const view = makeView({
associatedRefs: [{uri: 'at://did:plc:abc/app.bsky.feed.post/foo'}],
})
expect(isStandardSiteEmbed(view)).toBe(false)
})
it('returns falsy when associatedRefs is missing', () => {
expect(isStandardSiteEmbed(makeView({}))).toBeFalsy()
})
})
describe('isStandardSitePublicationEmbed', () => {
it('returns true with at least one publication ref and no document refs', () => {
const view = makeView({
associatedRefs: [{uri: 'at://did:plc:abc/site.standard.publication/foo'}],
})
expect(isStandardSitePublicationEmbed(view)).toBe(true)
})
it('returns false when any ref is a document', () => {
const view = makeView({
associatedRefs: [
{uri: 'at://did:plc:abc/site.standard.publication/foo'},
{uri: 'at://did:plc:abc/site.standard.document/bar'},
],
})
expect(isStandardSitePublicationEmbed(view)).toBe(false)
})
it('returns false when there are no publication refs', () => {
const view = makeView({
associatedRefs: [{uri: 'at://did:plc:abc/site.standard.other/foo'}],
})
expect(isStandardSitePublicationEmbed(view)).toBe(false)
})
it('returns falsy for an empty associatedRefs array', () => {
expect(isStandardSitePublicationEmbed(makeView({associatedRefs: []}))).toBe(
false,
)
})
it('returns falsy when associatedRefs is missing', () => {
expect(isStandardSitePublicationEmbed(makeView({}))).toBeFalsy()
})
})
@@ -0,0 +1,36 @@
import {
type AppBskyEmbedExternal,
AtUri,
type ComAtprotoRepoStrongRef,
} from '@atproto/api'
export function isStandardSiteDocumentUri(ref: ComAtprotoRepoStrongRef.Main) {
return new AtUri(ref.uri).collection.startsWith('site.standard.document')
}
export function isStandardSitePublicationUri(
ref: ComAtprotoRepoStrongRef.Main,
) {
return new AtUri(ref.uri).collection.startsWith('site.standard.publication')
}
export function isStandardSiteUri(ref: ComAtprotoRepoStrongRef.Main) {
return new AtUri(ref.uri).collection.startsWith('site.standard.')
}
export function isStandardSiteEmbed(view: AppBskyEmbedExternal.ViewExternal) {
return view.associatedRefs?.some(ref => isStandardSiteUri(ref))
}
export function isStandardSitePublicationEmbed(
view: AppBskyEmbedExternal.ViewExternal,
) {
return (
view.associatedRefs?.some(
ref => new AtUri(ref.uri).collection === 'site.standard.publication',
) &&
view.associatedRefs.every(
ref => new AtUri(ref.uri).collection !== 'site.standard.document',
)
)
}
+14
View File
@@ -22,6 +22,8 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
import {GalleryBleed} from '#/components/images/Gallery'
import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed'
import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
import {RichText} from '#/components/RichText'
import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {SubtleHover} from '#/components/SubtleHover'
@@ -95,6 +97,18 @@ function MediaEmbed({
)
}
case 'link': {
if (isStandardSiteEmbed(embed.view.external)) {
return (
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
activeStyle={[a.mt_sm]}>
<StandardSiteEmbed
view={embed.view.external}
style={[a.mt_sm, rest.style]}
/>
</ContentHider>
)
}
return (
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from '../TEMPLATE'
export const Leaflet = createSinglePathSVG({
path: 'M16.091 3.001c.385-.01 1.07.04 1.51.646q.122.17.186.334c.317-.029.759-.066 1.17-.032.395.034 1.195.163 1.661.9.411.65.308 1.316.107 1.768a3.3 3.3 0 0 1-.416.667c.142.124.302.293.428.507.302.514.316 1.083.182 1.62-.236.944-1.025 1.356-1.504 1.54-.493.19-1.07.294-1.502.371l-.05.009-.1.017c.106.368.084.759-.039 1.13-.34 1.025-1.114 1.513-1.85 1.718-.632.176-1.337.176-1.839.175h-.18c.126.508-.018 1.009-.158 1.348-.52 1.25-1.696 1.717-2.647 1.915s-2.052.204-2.966.21h-.063c-.5.002-.945.005-1.334.027-.345.666-.63 1.407-.962 2.271v.003l-.27.701c-.37.943-1.413 1.4-2.33 1.02-.918-.378-1.364-1.45-.996-2.392.473-1.207.962-2.049 1.615-3.342.175-.348.322-.628.452-.889 0-.786.057-1.341.17-2.045.19-1.183.35-1.639.585-2.718.215-.746.532-1.747 1.03-2.299.285-.317.727-.78 1.392-.812.404-.02.656.315.869.457.618-.86 1.143-2.005 1.82-2.894.542-.713 1.125-1.336 2.067-1.188.485.077 1.176.452 1.176 1.188.18-.205.729-.995.96-1.188.44-.367 1.048-.721 1.826-.743m-.347.953c-.828.023-1.412.747-1.902 1.354-.355.44-.66.82-.975.828-.308.008-.285-.204-.256-.473.042-.383.095-.88-.78-1.02-.726-.114-1.492 1.266-2.157 2.464-.455.82-.864 1.555-1.18 1.667-.482.172-.705-.03-.896-.202-.23-.208-.413-.373-.947.22-.563.623-.947 2.328-1.319 4.186-.047.237.283.39.434.203.856-1.057 2.248-2.51 3.908-3.585 1.526-.988 2.998-1.97 4.664-2.464.245-.073.315.21.08.312-1.878.82-3.491 1.903-5.181 3.348-1.9 1.589-3.696 3.703-4.607 5.427a22 22 0 0 0-1.511 3.127.6.6 0 0 0 .32.771.573.573 0 0 0 .751-.329q.134-.345.262-.68c.39-1.02.756-1.976 1.271-2.845a.21.21 0 0 1 .154-.101c.56-.069 1.227-.073 1.917-.077 1.881-.01 3.936-.023 4.487-1.348.287-.692-.06-.792-.373-.881-.255-.074-.489-.141-.335-.52.268-.655 1.138-.655 2.061-.655 1.075 0 2.22 0 2.563-1.036.147-.443-.115-.618-.347-.772-.194-.129-.366-.244-.262-.492.167-.394.89-.525 1.658-.663.917-.165 1.895-.34 2.057-.99.154-.614-.133-.812-.429-1.017-.18-.126-.364-.255-.454-.481-.11-.279.13-.576.38-.885.296-.365.604-.746.362-1.13-.276-.435-1.096-.358-1.775-.295-.424.04-.793.073-.94-.025-.148-.1-.142-.268-.136-.436.009-.262.017-.521-.566-.505',
})
@@ -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',
})
+9
View File
@@ -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',
})
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from '../TEMPLATE'
export const StandardSite = createSinglePathSVG({
path: 'M11.846 2.047a.275.275 0 0 1 .308 0c.108.073.166.791.283 2.227l.04.499h2.76a3.955 3.955 0 0 1 3.954 3.954v.682h-1.364v-.682a2.59 2.59 0 0 0-2.59-2.59H12.59c.123 1.38.245 2.183.605 2.84a4.37 4.37 0 0 0 1.688 1.708c.808.455 1.845.556 3.918.759l1.14.11c1.294.127 1.942.19 2.014.298a.27.27 0 0 1-.003.304c-.073.107-.722.16-2.018.266l-.743.06v2.79a3.954 3.954 0 0 1-3.955 3.955h-2.762l-.037.467c-.116 1.457-.174 2.185-.283 2.259a.275.275 0 0 1-.308 0c-.108-.073-.166-.802-.282-2.259l-.038-.467H8.691a3.955 3.955 0 0 1-3.955-3.954v-.682H6.1v.682a2.59 2.59 0 0 0 2.59 2.59h2.725c-.124-1.433-.247-2.26-.618-2.932a4.37 4.37 0 0 0-1.72-1.715c-.823-.452-1.878-.538-3.988-.71l-1.023-.084c-1.296-.106-1.945-.16-2.018-.266a.27.27 0 0 1-.003-.304c.072-.108.72-.171 2.015-.297l.676-.066V8.727a3.955 3.955 0 0 1 3.955-3.954h2.832l.04-.5c.117-1.435.175-2.153.283-2.226m5.981 10.55c-1.414.126-2.235.252-2.903.619a4.37 4.37 0 0 0-1.72 1.715c-.372.672-.495 1.499-.62 2.933h2.652a2.59 2.59 0 0 0 2.591-2.591zm-9.136-6.46A2.59 2.59 0 0 0 6.1 8.726v2.627c1.492-.153 2.336-.285 3.017-.669a4.37 4.37 0 0 0 1.688-1.707c.36-.658.483-1.46.605-2.842z',
})
+1
View File
@@ -442,6 +442,7 @@ async function resolveMedia(
title: resolvedLink.title,
description: resolvedLink.description,
thumb: blob,
associatedRefs: resolvedLink.associatedRefs,
},
}
}
+4 -2
View File
@@ -39,7 +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']
associatedRefs?: LinkMeta['associatedRefs']
view?: LinkMeta['view']
}
type ResolvedPostRecord = {
@@ -245,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,
}
}
+5 -3
View File
@@ -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
}
+4 -2
View File
@@ -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,
})
}
+17
View File
@@ -12,6 +12,8 @@ import {Loader} from '#/components/Loader'
import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed'
import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed'
import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed'
import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed'
import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
import {Embed as StarterPackEmbed} from '#/components/StarterPack/StarterPackCard'
import {Text} from '#/components/Typography'
import {type Gif} from '#/features/gifPicker/types'
@@ -86,6 +88,21 @@ export const ExternalEmbedLink = ({
const linkComponent = useMemo(() => {
if (data) {
if (data.type === 'external') {
if (data.view && isStandardSiteEmbed(data.view.external)) {
return (
<StandardSiteEmbed
hideSubscribe
view={{
...data.view?.external,
title: data.view?.external?.title || data.title || uri,
uri,
description:
data.view?.external?.description || data.description,
thumb: data.view?.external?.thumb || data.thumb?.source.path,
}}
/>
)
}
return (
<ExternalEmbed
link={{
+5 -1
View File
@@ -1,6 +1,7 @@
import {memo, useCallback, useMemo, useState} from 'react'
import {
Image as RNImage,
type ImageStyle,
Pressable,
type StyleProp,
StyleSheet,
@@ -76,6 +77,7 @@ interface UserAvatarProps extends BaseUserAvatarProps {
noBorder?: boolean
onLoad?: () => void
style?: StyleProp<ViewStyle>
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 [