get dimensions for video and refactor images (#1366)
* get dimensions for video and refactor images * improve rendering performance * more rendering perf enhancements
This commit is contained in:
parent
3f0499b96e
commit
2f546facb2
|
@ -78,6 +78,7 @@ IMGPROXY_MAX_ANIMATION_FRAME_RESOLUTION=200
|
|||
IMGPROXY_READ_TIMEOUT=10
|
||||
IMGPROXY_WRITE_TIMEOUT=10
|
||||
IMGPROXY_DOWNLOAD_TIMEOUT=9
|
||||
IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1
|
||||
# IMGPROXY_DEVELOPMENT_ERRORS_MODE=1
|
||||
# IMGPROXY_ENABLE_DEBUG_HEADERS=true
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import BootstrapForm from 'react-bootstrap/Form'
|
|||
import EditImage from '@/svgs/image-edit-fill.svg'
|
||||
import Moon from '@/svgs/moon-fill.svg'
|
||||
import { useShowModal } from './modal'
|
||||
import { ImageUpload } from './image'
|
||||
import { ImageUpload } from './image-upload'
|
||||
|
||||
export default function Avatar ({ onSuccess }) {
|
||||
const [uploading, setUploading] = useState()
|
||||
|
|
|
@ -23,7 +23,7 @@ import textAreaCaret from 'textarea-caret'
|
|||
import ReactDatePicker from 'react-datepicker'
|
||||
import 'react-datepicker/dist/react-datepicker.css'
|
||||
import useDebounceCallback, { debounce } from './use-debounce-callback'
|
||||
import { ImageUpload } from './image'
|
||||
import { ImageUpload } from './image-upload'
|
||||
import { AWS_S3_URL_REGEXP } from '@/lib/constants'
|
||||
import { whenRange } from '@/lib/time'
|
||||
import { useFeeButton } from './fee-button'
|
||||
|
|
|
@ -1,197 +1,10 @@
|
|||
import styles from './text.module.css'
|
||||
import { Fragment, useState, useEffect, useMemo, useCallback, forwardRef, useRef, memo } from 'react'
|
||||
import { IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP } from '@/lib/url'
|
||||
import { useShowModal } from './modal'
|
||||
import { useMe } from './me'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { UNKNOWN_LINK_REL, UPLOAD_TYPES_ALLOW, MEDIA_URL } from '@/lib/constants'
|
||||
import { Fragment, useCallback, forwardRef, useRef } from 'react'
|
||||
import { UPLOAD_TYPES_ALLOW, MEDIA_URL } from '@/lib/constants'
|
||||
import { useToast } from './toast'
|
||||
import gql from 'graphql-tag'
|
||||
import { useMutation } from '@apollo/client'
|
||||
import piexif from 'piexifjs'
|
||||
|
||||
export function decodeOriginalUrl (imgproxyUrl) {
|
||||
const parts = imgproxyUrl.split('/')
|
||||
// base64url is not a known encoding in browsers
|
||||
// so we need to replace the invalid chars
|
||||
const b64Url = parts[parts.length - 1].replace(/-/g, '+').replace(/_/, '/')
|
||||
const originalUrl = Buffer.from(b64Url, 'base64').toString('utf-8')
|
||||
return originalUrl
|
||||
}
|
||||
|
||||
function LinkRaw ({ href, children, src, rel, onClick, ...props }) {
|
||||
const isRawURL = /^https?:\/\//.test(children?.[0])
|
||||
return (
|
||||
// eslint-disable-next-line
|
||||
<a
|
||||
target='_blank'
|
||||
rel={rel ?? UNKNOWN_LINK_REL}
|
||||
href={src}
|
||||
>{isRawURL || !children ? src : children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageOriginal ({ src, topLevel, tab, onClick, ...props }) {
|
||||
const me = useMe()
|
||||
const [showImage, setShowImage] = useState(false)
|
||||
const [showVideo, setShowVideo] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// make sure it's not a false negative by trying to load URL as <img>
|
||||
const img = new window.Image()
|
||||
img.onload = () => setShowImage(true)
|
||||
img.src = src
|
||||
const video = document.createElement('video')
|
||||
video.onloadeddata = () => setShowVideo(true)
|
||||
video.src = src
|
||||
|
||||
return () => {
|
||||
img.onload = null
|
||||
img.src = ''
|
||||
video.onloadeddata = null
|
||||
video.src = ''
|
||||
}
|
||||
}, [src, showImage])
|
||||
|
||||
const showMedia = (tab === 'preview' || (me?.privates?.showImagesAndVideos !== false && !me?.privates?.imgproxyOnly))
|
||||
|
||||
if (showImage && showMedia) {
|
||||
return (
|
||||
<img
|
||||
className={topLevel ? styles.topLevel : undefined}
|
||||
src={src}
|
||||
onClick={() => onClick(src)}
|
||||
onError={() => setShowImage(false)}
|
||||
/>
|
||||
)
|
||||
} else if (showVideo && showMedia) {
|
||||
return <video src={src} controls />
|
||||
} else {
|
||||
// user is not okay with loading original url automatically or there was an error loading the image
|
||||
|
||||
// If element parsed by markdown is a raw URL, we use src as the text to not mislead users.
|
||||
// This will not be the case if [text](url) format is used. Then we will show what was chosen as text.
|
||||
return <LinkRaw src={src} {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
function TrustedImage ({ src, srcSet: { dimensions, ...srcSetObj } = {}, onClick, topLevel, onError, ...props }) {
|
||||
const srcSet = useMemo(() => {
|
||||
if (Object.keys(srcSetObj).length === 0) return undefined
|
||||
// srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... }
|
||||
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url], i, arr) => {
|
||||
// backwards compatibility: we used to replace image urls with imgproxy urls rather just storing paths
|
||||
if (!url.startsWith('http')) {
|
||||
url = new URL(url, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString()
|
||||
}
|
||||
return acc + `${url} ${wDescriptor}` + (i < arr.length - 1 ? ', ' : '')
|
||||
}, '')
|
||||
}, [srcSetObj])
|
||||
const sizes = srcSet ? `${(topLevel ? 100 : 66)}vw` : undefined
|
||||
|
||||
// get source url in best resolution
|
||||
const bestResSrc = useMemo(() => {
|
||||
if (Object.keys(srcSetObj).length === 0) return src
|
||||
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url]) => {
|
||||
if (!url.startsWith('http')) {
|
||||
url = new URL(url, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString()
|
||||
}
|
||||
const w = Number(wDescriptor.replace(/w$/, ''))
|
||||
return w > acc.w ? { w, url } : acc
|
||||
}, { w: 0, url: undefined }).url
|
||||
}, [srcSetObj])
|
||||
|
||||
const handleError = useCallback(onError, [onError])
|
||||
const handleClick = useCallback(() => onClick(bestResSrc), [onClick, bestResSrc])
|
||||
|
||||
return (
|
||||
<Image
|
||||
className={topLevel ? styles.topLevel : undefined}
|
||||
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
|
||||
src={bestResSrc}
|
||||
srcSet={srcSet}
|
||||
sizes={sizes}
|
||||
width={dimensions?.width}
|
||||
height={dimensions?.height}
|
||||
onClick={handleClick}
|
||||
onError={handleError}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const Image = memo(({ className, src, srcSet, sizes, width, height, onClick, onError }) => {
|
||||
const style = width && height
|
||||
? { '--height': `${height}px`, '--width': `${width}px`, '--aspect-ratio': `${width} / ${height}` }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
|
||||
src={src}
|
||||
srcSet={srcSet}
|
||||
sizes={sizes}
|
||||
width={width}
|
||||
height={height}
|
||||
onClick={onClick}
|
||||
onError={onError}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export default function ZoomableImage ({ src, srcSet, ...props }) {
|
||||
const showModal = useShowModal()
|
||||
const me = useMe()
|
||||
|
||||
// if `srcSet` is falsy, it means the image was not processed by worker yet
|
||||
const [trustedDomain, setTrustedDomain] = useState(!!srcSet || IMGPROXY_URL_REGEXP.test(src) || MEDIA_DOMAIN_REGEXP.test(src))
|
||||
|
||||
// backwards compatibility:
|
||||
// src may already be imgproxy url since we used to replace image urls with imgproxy urls
|
||||
const originalUrl = IMGPROXY_URL_REGEXP.test(src) ? decodeOriginalUrl(src) : src
|
||||
|
||||
const handleClick = useCallback((src) => showModal(close => {
|
||||
return (
|
||||
<div
|
||||
className={styles.fullScreenContainer}
|
||||
onClick={close}
|
||||
>
|
||||
<img className={styles.fullScreen} src={src} />
|
||||
</div>
|
||||
)
|
||||
}, {
|
||||
fullScreen: true,
|
||||
overflow: (
|
||||
<Dropdown.Item
|
||||
href={originalUrl} target='_blank'
|
||||
rel={props.rel ?? UNKNOWN_LINK_REL}
|
||||
>
|
||||
open original
|
||||
</Dropdown.Item>)
|
||||
}), [showModal, originalUrl, styles])
|
||||
|
||||
const handleError = useCallback(() => setTrustedDomain(false), [setTrustedDomain])
|
||||
|
||||
if (!src) return null
|
||||
|
||||
if (trustedDomain) {
|
||||
if (props.tab === 'preview' || !me || me.privates.showImagesAndVideos) {
|
||||
return (
|
||||
<TrustedImage
|
||||
src={src} srcSet={srcSet}
|
||||
onClick={handleClick} onError={handleError} {...props}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return <LinkRaw src={src} onClick={handleClick} {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
return <ImageOriginal src={originalUrl} onClick={handleClick} {...props} />
|
||||
}
|
||||
|
||||
export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload, onSuccess, onError, multiple, avatar }, ref) => {
|
||||
const toaster = useToast()
|
||||
ref ??= useRef(null)
|
|
@ -3,7 +3,7 @@ import ItemJob from './item-job'
|
|||
import Reply from './reply'
|
||||
import Comment from './comment'
|
||||
import Text, { SearchText } from './text'
|
||||
import ZoomableImage from './image'
|
||||
import MediaOrLink from './media-or-link'
|
||||
import Comments from './comments'
|
||||
import styles from '@/styles/item.module.css'
|
||||
import itemStyles from './item.module.css'
|
||||
|
@ -131,7 +131,7 @@ function ItemEmbed ({ item }) {
|
|||
}
|
||||
|
||||
if (item.url?.match(IMGPROXY_URL_REGEXP)) {
|
||||
return <ZoomableImage src={item.url} rel={item.rel ?? UNKNOWN_LINK_REL} />
|
||||
return <MediaOrLink src={item.url} rel={item.rel ?? UNKNOWN_LINK_REL} />
|
||||
}
|
||||
|
||||
return null
|
||||
|
|
|
@ -0,0 +1,170 @@
|
|||
import styles from './text.module.css'
|
||||
import { useState, useEffect, useMemo, useCallback, memo } from 'react'
|
||||
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP } from '@/lib/url'
|
||||
import { useShowModal } from './modal'
|
||||
import { useMe } from './me'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||
import classNames from 'classnames'
|
||||
|
||||
function LinkRaw ({ href, children, src, rel }) {
|
||||
const isRawURL = /^https?:\/\//.test(children?.[0])
|
||||
return (
|
||||
// eslint-disable-next-line
|
||||
<a
|
||||
target='_blank'
|
||||
rel={rel ?? UNKNOWN_LINK_REL}
|
||||
href={src}
|
||||
>{isRawURL || !children ? src : children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const Media = memo(function Media ({ src, bestResSrc, srcSet, sizes, width, height, onClick, onError, style, className, video }) {
|
||||
return (
|
||||
<div className={classNames(className, styles.mediaContainer)} style={style}>
|
||||
{video
|
||||
? <video
|
||||
src={src}
|
||||
preload={bestResSrc !== src ? 'metadata' : undefined}
|
||||
controls
|
||||
poster={bestResSrc !== src ? bestResSrc : undefined}
|
||||
width={width}
|
||||
height={height}
|
||||
onError={onError}
|
||||
/>
|
||||
: <img
|
||||
src={src}
|
||||
srcSet={srcSet}
|
||||
sizes={sizes}
|
||||
width={width}
|
||||
height={height}
|
||||
onClick={onClick}
|
||||
onError={onError}
|
||||
/>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default function MediaOrLink (props) {
|
||||
const media = useMediaHelper(props)
|
||||
const [error, setError] = useState(false)
|
||||
const showModal = useShowModal()
|
||||
|
||||
const handleClick = useCallback(() => showModal(close => {
|
||||
return (
|
||||
<div
|
||||
className={styles.fullScreenContainer}
|
||||
onClick={close}
|
||||
>
|
||||
<img className={styles.fullScreen} src={media.bestResSrc} />
|
||||
</div>
|
||||
)
|
||||
}, {
|
||||
fullScreen: true,
|
||||
overflow: (
|
||||
<Dropdown.Item
|
||||
href={media.originalSrc} target='_blank'
|
||||
rel={props.rel ?? UNKNOWN_LINK_REL}
|
||||
>
|
||||
open original
|
||||
</Dropdown.Item>)
|
||||
}), [showModal, media.originalSrc, styles, media.bestResSrc])
|
||||
|
||||
const handleError = useCallback((err) => {
|
||||
console.error('Error loading media', err)
|
||||
setError(true)
|
||||
}, [setError])
|
||||
|
||||
if (!media.src) return null
|
||||
|
||||
if (!error && (media.image || media.video)) {
|
||||
return (
|
||||
<Media
|
||||
{...media} onClick={handleClick} onError={handleError}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <LinkRaw {...props} />
|
||||
}
|
||||
|
||||
// determines how the media should be displayed given the params, me settings, and editor tab
|
||||
const useMediaHelper = ({ src, srcSet: { dimensions, video, ...srcSetObj } = {}, topLevel, tab }) => {
|
||||
const me = useMe()
|
||||
const trusted = useMemo(() => !!srcSetObj || IMGPROXY_URL_REGEXP.test(src) || MEDIA_DOMAIN_REGEXP.test(src), [!!srcSetObj, src])
|
||||
const [isImage, setIsImage] = useState(!video && trusted)
|
||||
const [isVideo, setIsVideo] = useState(video)
|
||||
const showMedia = useMemo(() => tab === 'preview' || me?.privates?.showImagesAndVideos !== false, [tab, me?.privates?.showImagesAndVideos])
|
||||
|
||||
useEffect(() => {
|
||||
// don't load the video at all if use doesn't want these
|
||||
if (!showMedia || isVideo || isImage) return
|
||||
// make sure it's not a false negative by trying to load URL as <img>
|
||||
const img = new window.Image()
|
||||
img.onload = () => setIsImage(true)
|
||||
img.src = src
|
||||
const video = document.createElement('video')
|
||||
video.onloadeddata = () => setIsVideo(true)
|
||||
video.src = src
|
||||
|
||||
return () => {
|
||||
img.onload = null
|
||||
img.src = ''
|
||||
video.onloadeddata = null
|
||||
video.src = ''
|
||||
}
|
||||
}, [src, setIsImage, setIsVideo, showMedia, isVideo])
|
||||
|
||||
const srcSet = useMemo(() => {
|
||||
if (Object.keys(srcSetObj).length === 0) return undefined
|
||||
// srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... }
|
||||
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url], i, arr) => {
|
||||
// backwards compatibility: we used to replace image urls with imgproxy urls rather just storing paths
|
||||
if (!url.startsWith('http')) {
|
||||
url = new URL(url, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString()
|
||||
}
|
||||
return acc + `${url} ${wDescriptor}` + (i < arr.length - 1 ? ', ' : '')
|
||||
}, '')
|
||||
}, [srcSetObj])
|
||||
const sizes = useMemo(() => srcSet ? `${(topLevel ? 100 : 66)}vw` : undefined)
|
||||
|
||||
// get source url in best resolution
|
||||
const bestResSrc = useMemo(() => {
|
||||
if (Object.keys(srcSetObj).length === 0) return src
|
||||
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url]) => {
|
||||
if (!url.startsWith('http')) {
|
||||
url = new URL(url, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString()
|
||||
}
|
||||
const w = Number(wDescriptor.replace(/w$/, ''))
|
||||
return w > acc.w ? { w, url } : acc
|
||||
}, { w: 0, url: undefined }).url
|
||||
}, [srcSetObj])
|
||||
|
||||
const [style, width, height] = useMemo(() => {
|
||||
if (dimensions) {
|
||||
const { width, height } = dimensions
|
||||
const style = {
|
||||
'--height': `${height}px`,
|
||||
'--width': `${width}px`,
|
||||
'--aspect-ratio': `${width} / ${height}`
|
||||
}
|
||||
return [style, width, height]
|
||||
}
|
||||
return []
|
||||
}, [dimensions?.width, dimensions?.height])
|
||||
|
||||
return {
|
||||
src,
|
||||
srcSet,
|
||||
originalSrc: IMGPROXY_URL_REGEXP.test(src) ? decodeProxyUrl(src) : src,
|
||||
sizes,
|
||||
bestResSrc,
|
||||
style,
|
||||
width,
|
||||
height,
|
||||
className: classNames(topLevel && styles.topLevel),
|
||||
image: (!me?.privates?.imgproxyOnly || trusted) && showMedia && isImage && !isVideo,
|
||||
video: !me?.privates?.imgproxyOnly && showMedia && isVideo
|
||||
}
|
||||
}
|
|
@ -12,8 +12,8 @@ import LinkIcon from '@/svgs/link.svg'
|
|||
import Thumb from '@/svgs/thumb-up-fill.svg'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
import copy from 'clipboard-copy'
|
||||
import ZoomableImage, { decodeOriginalUrl } from './image'
|
||||
import { IMGPROXY_URL_REGEXP, parseInternalLinks, parseEmbedUrl } from '@/lib/url'
|
||||
import MediaOrLink from './media-or-link'
|
||||
import { IMGPROXY_URL_REGEXP, parseInternalLinks, parseEmbedUrl, decodeProxyUrl } from '@/lib/url'
|
||||
import reactStringReplace from 'react-string-replace'
|
||||
import { rehypeInlineCodeProperty, rehypeStyler } from '@/lib/md'
|
||||
import { Button } from 'react-bootstrap'
|
||||
|
@ -82,7 +82,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||
}
|
||||
}, [containerRef.current, setOverflowing])
|
||||
|
||||
const slugger = new GithubSlugger()
|
||||
const slugger = useMemo(() => new GithubSlugger(), [])
|
||||
|
||||
const Heading = useCallback(({ children, node, ...props }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
@ -120,7 +120,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||
</a>}
|
||||
</span>
|
||||
)
|
||||
}, [topLevel, noFragments, slugger.current])
|
||||
}, [topLevel, noFragments, slugger])
|
||||
|
||||
const Table = useCallback(({ node, ...props }) =>
|
||||
<span className='table-responsive'>
|
||||
|
@ -143,181 +143,186 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||
|
||||
const P = useCallback(({ children, node, ...props }) => <div className={styles.p} {...props}>{children}</div>, [])
|
||||
|
||||
const Img = useCallback(({ node, src, ...props }) => {
|
||||
const url = IMGPROXY_URL_REGEXP.test(src) ? decodeOriginalUrl(src) : src
|
||||
// if outlawed, render the image link as text
|
||||
const TextMediaOrLink = useCallback(({ node, src, ...props }) => {
|
||||
const url = IMGPROXY_URL_REGEXP.test(src) ? decodeProxyUrl(src) : src
|
||||
// if outlawed, render the media link as text
|
||||
if (outlawed) {
|
||||
return url
|
||||
}
|
||||
const srcSet = imgproxyUrls?.[url]
|
||||
return <ZoomableImage srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel={topLevel} />
|
||||
return <MediaOrLink srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel={topLevel} />
|
||||
}, [imgproxyUrls, topLevel, tab])
|
||||
|
||||
const components = useMemo(() => ({
|
||||
h1: Heading,
|
||||
h2: Heading,
|
||||
h3: Heading,
|
||||
h4: Heading,
|
||||
h5: Heading,
|
||||
h6: Heading,
|
||||
table: Table,
|
||||
p: P,
|
||||
li: props => {
|
||||
return <li {...props} id={props.id && itemId ? `${props.id}-${itemId}` : props.id} />
|
||||
},
|
||||
code: Code,
|
||||
a: ({ node, href, children, ...props }) => {
|
||||
children = children ? Array.isArray(children) ? children : [children] : []
|
||||
// don't allow zoomable images to be wrapped in links
|
||||
if (children.some(e => e?.props?.node?.tagName === 'img')) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// if outlawed, render the link as text
|
||||
if (outlawed) {
|
||||
return href
|
||||
}
|
||||
|
||||
// If [text](url) was parsed as <a> and text is not empty and not a link itself,
|
||||
// we don't render it as an image since it was probably a conscious choice to include text.
|
||||
const text = children[0]
|
||||
let url
|
||||
try {
|
||||
url = !href.startsWith('/') && new URL(href)
|
||||
} catch {
|
||||
// ignore invalid URLs
|
||||
}
|
||||
|
||||
const internalURL = process.env.NEXT_PUBLIC_URL
|
||||
if (!!text && !/^https?:\/\//.test(text)) {
|
||||
if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') {
|
||||
return (
|
||||
<Link
|
||||
{...props}
|
||||
id={props.id && itemId ? `${props.id}-${itemId}` : props.id}
|
||||
href={itemId ? `${href}-${itemId}` : href}
|
||||
>{text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
if (text.startsWith?.('@')) {
|
||||
// user mention might be within a markdown link like this: [@user foo bar](url)
|
||||
const name = text.replace('@', '').split(' ')[0]
|
||||
return (
|
||||
<UserPopover name={name}>
|
||||
<Link
|
||||
id={props.id}
|
||||
href={href}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
</UserPopover>
|
||||
)
|
||||
} else if (href.startsWith('/') || url?.origin === internalURL) {
|
||||
try {
|
||||
const { linkText } = parseInternalLinks(href)
|
||||
if (linkText) {
|
||||
return (
|
||||
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
||||
<Link href={href}>{text}</Link>
|
||||
</ItemPopover>
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore errors like invalid URLs
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
id={props.id}
|
||||
href={href}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
// eslint-disable-next-line
|
||||
<a id={props.id} target='_blank' rel={rel ?? UNKNOWN_LINK_REL} href={href}>{text}</a>
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { linkText } = parseInternalLinks(href)
|
||||
if (linkText) {
|
||||
return (
|
||||
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
||||
<Link href={href}>{linkText}</Link>
|
||||
</ItemPopover>
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore errors like invalid URLs
|
||||
}
|
||||
|
||||
const videoWrapperStyles = {
|
||||
maxWidth: topLevel ? '640px' : '320px',
|
||||
margin: '0.5rem 0',
|
||||
paddingRight: '15px'
|
||||
}
|
||||
|
||||
const { provider, id, meta } = parseEmbedUrl(href)
|
||||
// Youtube video embed
|
||||
if (provider === 'youtube') {
|
||||
return (
|
||||
<div style={videoWrapperStyles}>
|
||||
<YouTube
|
||||
videoId={id} className={styles.videoContainer} opts={{
|
||||
playerVars: {
|
||||
start: meta?.start || 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Rumble video embed
|
||||
if (provider === 'rumble') {
|
||||
return (
|
||||
<div style={videoWrapperStyles}>
|
||||
<div className={styles.videoContainer}>
|
||||
<iframe
|
||||
title='Rumble Video'
|
||||
allowFullScreen
|
||||
src={meta?.href}
|
||||
sandbox='allow-scripts'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'peertube') {
|
||||
return (
|
||||
<div style={videoWrapperStyles}>
|
||||
<div className={styles.videoContainer}>
|
||||
<iframe
|
||||
title='PeerTube Video'
|
||||
allowFullScreen
|
||||
src={meta?.href}
|
||||
sandbox='allow-scripts'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// assume the link is an image which will fallback to link if it's not
|
||||
return <TextMediaOrLink src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</TextMediaOrLink>
|
||||
},
|
||||
img: TextMediaOrLink
|
||||
}), [outlawed, rel, topLevel, itemId, Code, P, Heading, Table, TextMediaOrLink])
|
||||
|
||||
const remarkPlugins = useMemo(() => [gfm, mention, sub], [])
|
||||
const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], [])
|
||||
|
||||
return (
|
||||
<div className={`${styles.text} ${show ? styles.textUncontained : overflowing ? styles.textContained : ''}`} ref={containerRef}>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: Heading,
|
||||
h2: Heading,
|
||||
h3: Heading,
|
||||
h4: Heading,
|
||||
h5: Heading,
|
||||
h6: Heading,
|
||||
table: Table,
|
||||
p: P,
|
||||
li: props => {
|
||||
return <li {...props} id={props.id && itemId ? `${props.id}-${itemId}` : props.id} />
|
||||
},
|
||||
code: Code,
|
||||
a: ({ node, href, children, ...props }) => {
|
||||
children = children ? Array.isArray(children) ? children : [children] : []
|
||||
// don't allow zoomable images to be wrapped in links
|
||||
if (children.some(e => e?.props?.node?.tagName === 'img')) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// if outlawed, render the link as text
|
||||
if (outlawed) {
|
||||
return href
|
||||
}
|
||||
|
||||
// If [text](url) was parsed as <a> and text is not empty and not a link itself,
|
||||
// we don't render it as an image since it was probably a conscious choice to include text.
|
||||
const text = children[0]
|
||||
let url
|
||||
try {
|
||||
url = !href.startsWith('/') && new URL(href)
|
||||
} catch {
|
||||
// ignore invalid URLs
|
||||
}
|
||||
|
||||
const internalURL = process.env.NEXT_PUBLIC_URL
|
||||
if (!!text && !/^https?:\/\//.test(text)) {
|
||||
if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') {
|
||||
return (
|
||||
<Link
|
||||
{...props}
|
||||
id={props.id && itemId ? `${props.id}-${itemId}` : props.id}
|
||||
href={itemId ? `${href}-${itemId}` : href}
|
||||
>{text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
if (text.startsWith?.('@')) {
|
||||
// user mention might be within a markdown link like this: [@user foo bar](url)
|
||||
const name = text.replace('@', '').split(' ')[0]
|
||||
return (
|
||||
<UserPopover name={name}>
|
||||
<Link
|
||||
id={props.id}
|
||||
href={href}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
</UserPopover>
|
||||
)
|
||||
} else if (href.startsWith('/') || url?.origin === internalURL) {
|
||||
try {
|
||||
const { linkText } = parseInternalLinks(href)
|
||||
if (linkText) {
|
||||
return (
|
||||
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
||||
<Link href={href}>{text}</Link>
|
||||
</ItemPopover>
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore errors like invalid URLs
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
id={props.id}
|
||||
href={href}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
// eslint-disable-next-line
|
||||
<a id={props.id} target='_blank' rel={rel ?? UNKNOWN_LINK_REL} href={href}>{text}</a>
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { linkText } = parseInternalLinks(href)
|
||||
if (linkText) {
|
||||
return (
|
||||
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
||||
<Link href={href}>{linkText}</Link>
|
||||
</ItemPopover>
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore errors like invalid URLs
|
||||
}
|
||||
|
||||
const videoWrapperStyles = {
|
||||
maxWidth: topLevel ? '640px' : '320px',
|
||||
margin: '0.5rem 0',
|
||||
paddingRight: '15px'
|
||||
}
|
||||
|
||||
const { provider, id, meta } = parseEmbedUrl(href)
|
||||
// Youtube video embed
|
||||
if (provider === 'youtube') {
|
||||
return (
|
||||
<div style={videoWrapperStyles}>
|
||||
<YouTube
|
||||
videoId={id} className={styles.videoContainer} opts={{
|
||||
playerVars: {
|
||||
start: meta?.start || 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Rumble video embed
|
||||
if (provider === 'rumble') {
|
||||
return (
|
||||
<div style={videoWrapperStyles}>
|
||||
<div className={styles.videoContainer}>
|
||||
<iframe
|
||||
title='Rumble Video'
|
||||
allowFullScreen
|
||||
src={meta?.href}
|
||||
sandbox='allow-scripts'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'peertube') {
|
||||
return (
|
||||
<div style={videoWrapperStyles}>
|
||||
<div className={styles.videoContainer}>
|
||||
<iframe
|
||||
title='PeerTube Video'
|
||||
allowFullScreen
|
||||
src={meta?.href}
|
||||
sandbox='allow-scripts'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// assume the link is an image which will fallback to link if it's not
|
||||
return <Img src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</Img>
|
||||
},
|
||||
img: Img
|
||||
}}
|
||||
remarkPlugins={[gfm, mention, sub]}
|
||||
rehypePlugins={[rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript]}
|
||||
components={components}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
|
|
|
@ -128,24 +128,33 @@
|
|||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.text img, .text video {
|
||||
.text .mediaContainer {
|
||||
display: block;
|
||||
margin-top: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
max-height: 25vh;
|
||||
aspect-ratio: var(--aspect-ratio);
|
||||
}
|
||||
|
||||
.text img {
|
||||
cursor: zoom-in;
|
||||
.mediaContainer img, .mediaContainer video {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
width: auto;
|
||||
max-height: inherit;
|
||||
height: 100%;
|
||||
aspect-ratio: var(--aspect-ratio);
|
||||
}
|
||||
|
||||
.mediaContainer img {
|
||||
cursor: zoom-in;
|
||||
min-width: 30%;
|
||||
object-position: left top;
|
||||
}
|
||||
|
||||
.text img.topLevel, .text video.topLevel {
|
||||
.mediaContainer.topLevel {
|
||||
margin-top: .75rem;
|
||||
margin-bottom: .75rem;
|
||||
max-height: 35vh;
|
||||
|
|
|
@ -16,7 +16,7 @@ http:
|
|||
|
||||
# Configuration for your containers and service.
|
||||
image:
|
||||
location: ${PRIVATE_REPO}/imgproxy:v3.21.0-ml-amd64
|
||||
location: docker.imgproxy.pro/imgproxy:v3.25.0-ml-amd64
|
||||
# Port exposed through your container to route traffic to it.
|
||||
port: 8080
|
||||
|
||||
|
@ -49,6 +49,7 @@ variables: # Pass environment variables as key value pairs.
|
|||
IMGPROXY_WRITE_TIMEOUT: 10
|
||||
IMGPROXY_DOWNLOAD_TIMEOUT: 9
|
||||
IMGPROXY_WORKERS: 4
|
||||
IMGPROXY_ENABLE_VIDEO_THUMBNAILS: 1
|
||||
|
||||
secrets: # Pass secrets from AWS Systems Manager (SSM) Parameter Store.
|
||||
IMGPROXY_KEY: /copilot/${COPILOT_APPLICATION_NAME}/${COPILOT_ENVIRONMENT_NAME}/secrets/imgproxy_key
|
||||
|
|
|
@ -168,6 +168,15 @@ export function parseNwcUrl (walletConnectUrl) {
|
|||
return params
|
||||
}
|
||||
|
||||
export function decodeProxyUrl (imgproxyUrl) {
|
||||
const parts = imgproxyUrl.split('/')
|
||||
// base64url is not a known encoding in browsers
|
||||
// so we need to replace the invalid chars
|
||||
const b64Url = parts[parts.length - 1].replace(/-/g, '+').replace(/_/, '/')
|
||||
const originalUrl = Buffer.from(b64Url, 'base64').toString('utf-8')
|
||||
return originalUrl
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
export const URL_REGEXP = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import { createHmac } from 'node:crypto'
|
|||
import { extractUrls } from '@/lib/md.js'
|
||||
import { isJob } from '@/lib/item.js'
|
||||
import path from 'node:path'
|
||||
import { decodeProxyUrl } from '@/lib/url'
|
||||
|
||||
const imgProxyEnabled = process.env.NODE_ENV === 'production' ||
|
||||
(process.env.NEXT_PUBLIC_IMGPROXY_URL && process.env.IMGPROXY_SALT && process.env.IMGPROXY_KEY)
|
||||
|
@ -47,13 +48,6 @@ function matchUrl (matchers, url) {
|
|||
}
|
||||
}
|
||||
|
||||
function decodeOriginalUrl (imgproxyUrl) {
|
||||
const parts = imgproxyUrl.split('/')
|
||||
const b64Url = parts[parts.length - 1]
|
||||
const originalUrl = Buffer.from(b64Url, 'base64url').toString('utf-8')
|
||||
return originalUrl
|
||||
}
|
||||
|
||||
export async function imgproxy ({ data: { id, forceFetch = false }, models }) {
|
||||
if (!imgProxyEnabled) return
|
||||
|
||||
|
@ -100,18 +94,17 @@ export const createImgproxyUrls = async (id, text, { models, forceFetch }) => {
|
|||
if (url.startsWith(IMGPROXY_URL)) {
|
||||
console.log('[imgproxy] id:', id, '-- proxy url, decoding original url:', url)
|
||||
// backwards compatibility: we used to replace image urls with imgproxy urls
|
||||
url = decodeOriginalUrl(url)
|
||||
url = decodeProxyUrl(url)
|
||||
console.log('[imgproxy] id:', id, '-- original url:', url)
|
||||
}
|
||||
if (!(await isImageURL(fetchUrl, { forceFetch }))) {
|
||||
if (!(await isMediaURL(fetchUrl, { forceFetch }))) {
|
||||
console.log('[imgproxy] id:', id, '-- not image url:', url)
|
||||
continue
|
||||
}
|
||||
imgproxyUrls[url] = {}
|
||||
try {
|
||||
imgproxyUrls[url] = {
|
||||
dimensions: await getDimensions(fetchUrl)
|
||||
}
|
||||
imgproxyUrls[url] = await getMetadata(fetchUrl)
|
||||
console.log('[imgproxy] id:', id, '-- dimensions:', imgproxyUrls[url])
|
||||
} catch (err) {
|
||||
console.log('[imgproxy] id:', id, '-- error getting dimensions (possibly not running imgproxy pro)', err)
|
||||
}
|
||||
|
@ -124,12 +117,13 @@ export const createImgproxyUrls = async (id, text, { models, forceFetch }) => {
|
|||
return imgproxyUrls
|
||||
}
|
||||
|
||||
const getDimensions = async (url) => {
|
||||
const options = '/d:1'
|
||||
const getMetadata = async (url) => {
|
||||
// video metadata, dimensions, format
|
||||
const options = '/vm:1/d:1/f:1'
|
||||
const imgproxyUrl = new URL(createImgproxyPath({ url, options, pathname: '/info' }), IMGPROXY_URL).toString()
|
||||
const res = await fetch(imgproxyUrl)
|
||||
const { width, height } = await res.json()
|
||||
return { width, height }
|
||||
const { width, height, format, video_streams: videoStreams } = await res.json()
|
||||
return { dimensions: { width, height }, format, video: !!videoStreams?.length }
|
||||
}
|
||||
|
||||
const createImgproxyPath = ({ url, pathname = '/', options }) => {
|
||||
|
@ -152,7 +146,7 @@ async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {})
|
|||
return response
|
||||
}
|
||||
|
||||
const isImageURL = async (url, { forceFetch }) => {
|
||||
const isMediaURL = async (url, { forceFetch }) => {
|
||||
if (cache.has(url)) return cache.get(url)
|
||||
|
||||
if (!forceFetch && matchUrl(imageUrlMatchers, url)) {
|
||||
|
@ -162,21 +156,21 @@ const isImageURL = async (url, { forceFetch }) => {
|
|||
return false
|
||||
}
|
||||
|
||||
let isImage
|
||||
let isMedia
|
||||
|
||||
// first run HEAD with small timeout
|
||||
try {
|
||||
// https://stackoverflow.com/a/68118683
|
||||
const res = await fetchWithTimeout(url, { timeout: 1000, method: 'HEAD' })
|
||||
const buf = await res.blob()
|
||||
isImage = buf.type.startsWith('image/')
|
||||
isMedia = buf.type.startsWith('image/') || buf.type.startsWith('video/')
|
||||
} catch (err) {
|
||||
console.log(url, err)
|
||||
}
|
||||
|
||||
// For HEAD requests, positives are most likely true positives.
|
||||
// However, negatives may be false negatives
|
||||
if (isImage) {
|
||||
if (isMedia) {
|
||||
cache.set(url, true)
|
||||
return true
|
||||
}
|
||||
|
@ -185,13 +179,13 @@ const isImageURL = async (url, { forceFetch }) => {
|
|||
try {
|
||||
const res = await fetchWithTimeout(url, { timeout: 10000 })
|
||||
const buf = await res.blob()
|
||||
isImage = buf.type.startsWith('image/')
|
||||
isMedia = buf.type.startsWith('image/') || buf.type.startsWith('video/')
|
||||
} catch (err) {
|
||||
console.log(url, err)
|
||||
}
|
||||
|
||||
cache.set(url, isImage)
|
||||
return isImage
|
||||
cache.set(url, isMedia)
|
||||
return isMedia
|
||||
}
|
||||
|
||||
const hexDecode = (hex) => Buffer.from(hex, 'hex')
|
||||
|
|
Loading…
Reference in New Issue