[APP-2160] Build new standard.site link card UI (#10468)
Co-authored-by: vineyardbovines <spencer@thepope.dev> Co-authored-by: Eric Bailey <git@esb.lol> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
@@ -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",
|
||||
|
||||
Generated
+5
-5
@@ -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
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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',
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -442,6 +442,7 @@ async function resolveMedia(
|
||||
title: resolvedLink.title,
|
||||
description: resolvedLink.description,
|
||||
thumb: blob,
|
||||
associatedRefs: resolvedLink.associatedRefs,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user