Skip to content

Commit

Permalink
Images v2
Browse files Browse the repository at this point in the history
* moved imgproxy code into worker:
  - item table now has JSONB column `imgproxyUrls`
  - worker parses `text` and `url` and check whichs URLs are images
  - for every image URL found, we sign multiple proxy URLs for use with `srcset` (responsive images)

* we longer no replace image URLs
  - code is backwards compatible by decoding original URL for imgproxy URLs

* improved image detection:
  - fallback to GET if HEAD failed or returned negative
  - this shouldn't be a problem since this is done inside the worker with timeouts

* improved UI/UX: there are placeholder images now if original URLs should be loaded automatically:
  - placeholder if image is still processing
  - placeholder if there was an error loading the image from our image proxy

* moved image code in frontend to components/image.js
  • Loading branch information
ekzyis committed Sep 23, 2023
1 parent 4fe4b5e commit de79ccd
Show file tree
Hide file tree
Showing 19 changed files with 610 additions and 220 deletions.
4 changes: 3 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ NEXT_PUBLIC_IMGPROXY_URL=
IMGPROXY_KEY=
IMGPROXY_SALT=
IMGPROXY_ENABLE_WEBP_DETECTION=1
IMGPROXY_ENABLE_AVIF_DETECTION=1
IMGPROXY_MAX_ANIMATION_FRAMES=100
IMGPROXY_MAX_SRC_RESOLUTION=200
IMGPROXY_MAX_SRC_RESOLUTION=50
IMGPROXY_MAX_ANIMATION_FRAME_RESOLUTION=200
IMGPROXY_READ_TIMEOUT=10
IMGPROXY_WRITE_TIMEOUT=10
IMGPROXY_DOWNLOAD_TIMEOUT=9
Expand Down
69 changes: 0 additions & 69 deletions api/resolvers/imgproxy/index.js

This file was deleted.

11 changes: 1 addition & 10 deletions api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { parse } from 'tldts'
import uu from 'url-unshort'
import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
import { sendUserNotification } from '../webPush'
import { proxyImages } from './imgproxy'
import { defaultCommentSort } from '../../lib/item'
import { createHmac } from './wallet'
import { settleHodlInvoice } from 'ln-service'
Expand Down Expand Up @@ -1130,13 +1129,9 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } })
}

if (item.text) {
item.text = await proxyImages(item.text)
}
if (item.url && typeof item.maxBid === 'undefined') {
item.url = ensureProtocol(item.url)
item.url = removeTracking(item.url)
item.url = await proxyImages(item.url)
}

item = { subName, userId: me.id, ...item }
Expand Down Expand Up @@ -1176,13 +1171,9 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
}

const fwdUsers = await getForwardUsers(models, forward)
if (item.text) {
item.text = await proxyImages(item.text)
}
if (item.url && typeof item.maxBid === 'undefined') {
item.url = ensureProtocol(item.url)
item.url = removeTracking(item.url)
item.url = await proxyImages(item.url)
}

const trx = [
Expand Down Expand Up @@ -1254,7 +1245,7 @@ export const SELECT =
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats,
"Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",
"Item"."weightedDownVotes", "Item".freebie, "Item"."otsHash", "Item"."bountyPaidTo",
ltree2text("Item"."path") AS "path", "Item"."weightedComments"`
ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls"`

async function topOrderByWeightedSats (me, models) {
return `ORDER BY ${await orderByNumerator(me, models)} DESC NULLS LAST, "Item".id DESC`
Expand Down
1 change: 1 addition & 0 deletions api/typeDefs/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export default gql`
otsHash: String
parentOtsHash: String
forwards: [ItemForward]
imgproxyUrls: JSONObject
}
input ItemForwardInput {
Expand Down
2 changes: 1 addition & 1 deletion components/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export default function Comment ({
)
: (
<div className={styles.text}>
<Text topLevel={topLevel} nofollow={item.sats + item.boost < NOFOLLOW_LIMIT}>
<Text topLevel={topLevel} nofollow={item.sats + item.boost < NOFOLLOW_LIMIT} imgproxyUrls={item.imgproxyUrls}>
{truncate ? truncateString(item.text) : item.searchText || item.text}
</Text>
</div>
Expand Down
2 changes: 1 addition & 1 deletion components/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
: (
<div className='form-group'>
<div className={`${styles.text} form-control`}>
<Text topLevel={topLevel} noFragments fetchOnlyImgProxy={false}>{meta.value}</Text>
<Text topLevel={topLevel} noFragments>{meta.value}</Text>
</div>
</div>
)}
Expand Down
218 changes: 218 additions & 0 deletions components/image.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion components/item-full.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
}

function ItemText ({ item }) {
return <Text topLevel nofollow={item.sats + item.boost < NOFOLLOW_LIMIT}>{item.searchText || item.text}</Text>
return <Text topLevel nofollow={item.sats + item.boost < NOFOLLOW_LIMIT} imgproxyUrls={item.imgproxyUrls}>{item.searchText || item.text}</Text>
}

export default function ItemFull ({ item, bio, rank, ...props }) {
Expand Down
15 changes: 11 additions & 4 deletions components/modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,26 @@ export default function useModal () {
const onClose = useCallback(() => {
setModalContent(null)
setModalStack([])
}, [])
modalOptions?.onClose?.()
}, [modalOptions?.onClose])

const modal = useMemo(() => {
if (modalContent === null) {
return null
}
const className = modalOptions?.fullScreen ? 'fullscreen' : ''
return (
<Modal onHide={modalOptions?.keepOpen ? null : onClose} show={!!modalContent}>
<Modal
onHide={modalOptions?.keepOpen ? null : onClose} show={!!modalContent}
className={className}
dialogClassName={className}
contentClassName={className}
>
<div className='d-flex flex-row'>
{modalStack.length > 0 ? <div className='modal-btn modal-back' onClick={onBack}><BackArrow width={18} height={18} className='fill-white' /></div> : null}
<div className='modal-btn modal-close' onClick={onClose}>X</div>
<div className={'modal-btn modal-close ' + className} onClick={onClose}>X</div>
</div>
<Modal.Body>
<Modal.Body className={className}>
{modalContent}
</Modal.Body>
</Modal>
Expand Down
146 changes: 13 additions & 133 deletions components/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,14 @@ import sub from '../lib/remark-sub'
import remarkDirective from 'remark-directive'
import { visit } from 'unist-util-visit'
import reactStringReplace from 'react-string-replace'
import React, { useRef, useEffect, useState, memo } from 'react'
import React, { useState, memo } from 'react'
import GithubSlugger from 'github-slugger'
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 { IMGPROXY_URL_REGEXP, IMG_URL_REGEXP } from '../lib/url'
import { extractUrls } from '../lib/md'
import FileMissing from '../svgs/file-warning-line.svg'
import { useMe } from './me'
import { useImgUrlCache, IMG_CACHE_STATES, ZoomableImage, decodeOriginalUrl } from './image'
import { IMGPROXY_URL_REGEXP } from '../lib/url'

function searchHighlighter () {
return (tree) => {
Expand All @@ -36,15 +34,6 @@ function searchHighlighter () {
}
}

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 Heading ({ h, slugger, noFragments, topLevel, children, node, ...props }) {
const [copied, setCopied] = useState(false)
const [id] = useState(noFragments ? undefined : slugger.slug(toString(node).replace(/[^\w\-\s]+/gi, '')))
Expand Down Expand Up @@ -73,54 +62,14 @@ function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props
)
}

const CACHE_STATES = {
IS_LOADING: 'IS_LOADING',
IS_LOADED: 'IS_LOADED',
IS_ERROR: 'IS_ERROR'
}

// this is one of the slowest components to render
export default memo(function Text ({ topLevel, noFragments, nofollow, fetchOnlyImgProxy, children }) {
export default memo(function Text ({ topLevel, noFragments, nofollow, imgproxyUrls, children }) {
// all the reactStringReplace calls are to facilitate search highlighting
const slugger = new GithubSlugger()
fetchOnlyImgProxy ??= true

const HeadingWrapper = (props) => Heading({ topLevel, slugger, noFragments, ...props })

const imgCache = useRef({})
const [urlCache, setUrlCache] = useState({})

useEffect(() => {
const imgRegexp = fetchOnlyImgProxy ? IMGPROXY_URL_REGEXP : IMG_URL_REGEXP
const urls = extractUrls(children)

urls.forEach((url) => {
if (imgRegexp.test(url)) {
setUrlCache((prev) => ({ ...prev, [url]: CACHE_STATES.IS_LOADED }))
} else if (!fetchOnlyImgProxy) {
const img = new window.Image()
imgCache.current[url] = img

setUrlCache((prev) => ({ ...prev, [url]: CACHE_STATES.IS_LOADING }))

const callback = (state) => {
setUrlCache((prev) => ({ ...prev, [url]: state }))
delete imgCache.current[url]
}
img.onload = () => callback(CACHE_STATES.IS_LOADED)
img.onerror = () => callback(CACHE_STATES.IS_ERROR)
img.src = url
}
})

return () => {
Object.values(imgCache.current).forEach((img) => {
img.onload = null
img.onerror = null
img.src = ''
})
}
}, [children])
const imgUrlCache = useImgUrlCache(children)

return (
<div className={styles.text}>
Expand Down Expand Up @@ -159,8 +108,10 @@ export default memo(function Text ({ topLevel, noFragments, nofollow, fetchOnlyI
return <>{children}</>
}

if (urlCache[href] === CACHE_STATES.IS_LOADED) {
return <ZoomableImage topLevel={topLevel} useClickToLoad={fetchOnlyImgProxy} {...props} src={href} />
if (imgUrlCache[href] === IMG_CACHE_STATES.LOADED) {
const url = IMGPROXY_URL_REGEXP.test(href) ? decodeOriginalUrl(href) : href
const srcSet = imgproxyUrls ? imgproxyUrls[url] : undefined
return <ZoomableImage topLevel={topLevel} srcSet={srcSet} {...props} src={href} />
}

// map: fix any highlighted links
Expand All @@ -183,8 +134,10 @@ export default memo(function Text ({ topLevel, noFragments, nofollow, fetchOnlyI
</a>
)
},
img: ({ node, ...props }) => {
return <ZoomableImage topLevel={topLevel} useClickToLoad={fetchOnlyImgProxy} {...props} />
img: ({ node, src, ...props }) => {
const url = IMGPROXY_URL_REGEXP.test(src) ? decodeOriginalUrl(src) : src
const srcSet = imgproxyUrls ? imgproxyUrls[url] : undefined
return <ZoomableImage topLevel={topLevel} srcSet={srcSet} src={src} {...props} />
}
}}
remarkPlugins={[gfm, mention, sub, remarkDirective, searchHighlighter]}
Expand All @@ -194,76 +147,3 @@ export default memo(function Text ({ topLevel, noFragments, nofollow, fetchOnlyI
</div>
)
})

function ClickToLoad ({ children }) {
const [clicked, setClicked] = useState(false)
return clicked ? children : <div className='m-1 fst-italic pointer text-muted' onClick={() => setClicked(true)}>click to load image</div>
}

export function ZoomableImage ({ src, topLevel, useClickToLoad, ...props }) {
const me = useMe()
const [err, setErr] = useState()
const [imgSrc, setImgSrc] = useState(src)
const [isImgProxy, setIsImgProxy] = useState(IMGPROXY_URL_REGEXP.test(src))
const defaultMediaStyle = {
maxHeight: topLevel ? '75vh' : '25vh',
cursor: 'zoom-in'
}
useClickToLoad ??= true

// if image changes we need to update state
const [mediaStyle, setMediaStyle] = useState(defaultMediaStyle)
useEffect(() => {
setMediaStyle(defaultMediaStyle)
setErr(null)
}, [src])

if (!src) return null
if (err) {
if (!isImgProxy) {
return (
<span className='d-flex align-items-baseline text-warning-emphasis fw-bold pb-1'>
<FileMissing width={18} height={18} className='fill-warning me-1 align-self-center' />
image error
</span>
)
}
try {
const originalUrl = decodeOriginalUrl(src)
setImgSrc(originalUrl)
setErr(null)
} catch (err) {
console.error(err)
setErr(err)
}
// always set to false since imgproxy returned error
setIsImgProxy(false)
}

const img = (
<img
className={topLevel ? styles.topLevel : undefined}
style={mediaStyle}
src={imgSrc}
onClick={() => {
if (mediaStyle.cursor === 'zoom-in') {
setMediaStyle({
width: '100%',
cursor: 'zoom-out'
})
} else {
setMediaStyle(defaultMediaStyle)
}
}}
onError={() => setErr(true)}
{...props}
/>
)

return (
(!me || !me.clickToLoadImg || isImgProxy || !useClickToLoad)
? img
: <ClickToLoad>{img}</ClickToLoad>

)
}
1 change: 1 addition & 0 deletions fragments/comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const COMMENT_FIELDS = gql`
mine
otsHash
ncomments
imgproxyUrls
}
`

Expand Down
Loading

0 comments on commit de79ccd

Please sign in to comment.