Compare commits

...

2 Commits

Author SHA1 Message Date
Keyan 6f68a700ce
recognize video links (#1357) 2024-09-03 18:35:14 -05:00
Keyan adcf048f4e
improve dupes (#1356) 2024-09-03 14:29:45 -05:00
7 changed files with 56 additions and 39 deletions

View File

@ -570,40 +570,34 @@ export default {
}, },
dupes: async (parent, { url }, { me, models }) => { dupes: async (parent, { url }, { me, models }) => {
const urlObj = new URL(ensureProtocol(url)) const urlObj = new URL(ensureProtocol(url))
const { hostname, pathname } = urlObj let { hostname, pathname } = urlObj
let hostnameRegex = hostname + '(:[0-9]+)?' // remove subdomain from hostname
const parseResult = parse(urlObj.hostname) const parseResult = parse(urlObj.hostname)
if (parseResult?.subdomain?.length) { if (parseResult?.subdomain?.length > 0) {
const { subdomain } = parseResult hostname = hostname.replace(`${parseResult.subdomain}.`, '')
hostnameRegex = hostnameRegex.replace(subdomain, '(%)?')
} else {
hostnameRegex = `(%.)?${hostnameRegex}`
} }
// hostname with optional protocol, subdomain, and port
const hostnameRegex = `^(http(s)?:\\/\\/)?(\\w+\\.)?${(hostname + '(:[0-9]+)?').replace(/\./g, '\\.')}`
// pathname with trailing slash and escaped special characters
const pathnameRegex = stripTrailingSlash(pathname).replace(/(\+|\.|\/)/g, '\\$1') + '\\/?'
// url with optional trailing slash
let similar = hostnameRegex + pathnameRegex
// escape postgres regex meta characters
let pathnameRegex = pathname.replace(/\+/g, '\\+')
pathnameRegex = pathnameRegex.replace(/%/g, '\\%')
pathnameRegex = pathnameRegex.replace(/_/g, '\\_')
const uriRegex = stripTrailingSlash(hostnameRegex + pathnameRegex)
let similar = `(http(s)?://)?${uriRegex}/?`
const whitelist = ['news.ycombinator.com/item', 'bitcointalk.org/index.php'] const whitelist = ['news.ycombinator.com/item', 'bitcointalk.org/index.php']
const youtube = ['www.youtube.com', 'youtube.com', 'm.youtube.com', 'youtu.be'] const youtube = ['www.youtube.com', 'youtube.com', 'm.youtube.com', 'youtu.be']
const hostAndPath = stripTrailingSlash(urlObj.hostname + urlObj.pathname) const hostAndPath = stripTrailingSlash(urlObj.hostname + urlObj.pathname)
if (whitelist.includes(hostAndPath)) { if (whitelist.includes(hostAndPath)) {
// make query string match for whitelist domains
similar += `\\${urlObj.search}` similar += `\\${urlObj.search}`
} else if (youtube.includes(urlObj.hostname)) { } else if (youtube.includes(urlObj.hostname)) {
// extract id and create both links // extract id and create both links
const matches = url.match(/(https?:\/\/)?((www\.)?(youtube(-nocookie)?|youtube.googleapis)\.com.*(v\/|v=|vi=|vi\/|e\/|embed\/|user\/.*\/u\/\d+\/)|youtu\.be\/)(?<id>[_0-9a-z-]+)/i) const matches = url.match(/(https?:\/\/)?((www\.)?(youtube(-nocookie)?|youtube.googleapis)\.com.*(v\/|v=|vi=|vi\/|e\/|embed\/|user\/.*\/u\/\d+\/)|youtu\.be\/)(?<id>[_0-9a-z-]+)/i)
similar = `(http(s)?://)?((www.|m.)?youtube.com/(watch\\?v=|v/|live/)${matches?.groups?.id}|youtu.be/${matches?.groups?.id})((\\?|&|#)%)?` similar = `^(http(s)?:\\/\\/)?((www\\.|m\\.)?youtube.com\\/(watch\\?v\\=|v\\/|live\\/)${matches?.groups?.id}|youtu\\.be\\/${matches?.groups?.id})&?`
} else if (urlObj.hostname === 'yewtu.be') { } else if (urlObj.hostname === 'yewtu.be') {
const matches = url.match(/(https?:\/\/)?yewtu\.be.*(v=|embed\/)(?<id>[_0-9a-z-]+)/i) const matches = url.match(/(https?:\/\/)?yewtu\.be.*(v=|embed\/)(?<id>[_0-9a-z-]+)/i)
similar = `(http(s)?://)?yewtu.be/(watch\\?v=|embed/)${matches?.groups?.id}((\\?|&|#)%)?` similar = `^(http(s)?:\\/\\/)?yewtu\\.be\\/(watch\\?v\\=|embed\\/)${matches?.groups?.id}&?`
} else {
similar += '((\\?|#)%)?'
} }
return await itemQueryWithMeta({ return await itemQueryWithMeta({
@ -612,7 +606,7 @@ export default {
query: ` query: `
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
WHERE LOWER(url) SIMILAR TO LOWER($1) WHERE url ~* $1
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 3` LIMIT 3`
}, similar) }, similar)

View File

@ -43,7 +43,7 @@ export default function AccordianItem ({ header, body, headerColor = 'var(--them
return ( return (
<Accordion defaultActiveKey={activeKey} activeKey={activeKey} onSelect={handleOnSelect}> <Accordion defaultActiveKey={activeKey} activeKey={activeKey} onSelect={handleOnSelect}>
<ContextAwareToggle show={show} eventKey={KEY_ID}><div style={{ color: headerColor }}>{header}</div></ContextAwareToggle> <ContextAwareToggle show={show} eventKey={KEY_ID} headerColor={headerColor}><div style={{ color: headerColor }}>{header}</div></ContextAwareToggle>
<Accordion.Collapse eventKey={KEY_ID} className='mt-2'> <Accordion.Collapse eventKey={KEY_ID} className='mt-2'>
<div>{body}</div> <div>{body}</div>
</Accordion.Collapse> </Accordion.Collapse>

View File

@ -22,6 +22,7 @@ export function decodeOriginalUrl (imgproxyUrl) {
function ImageOriginal ({ src, topLevel, rel, tab, children, onClick, ...props }) { function ImageOriginal ({ src, topLevel, rel, tab, children, onClick, ...props }) {
const me = useMe() const me = useMe()
const [showImage, setShowImage] = useState(false) const [showImage, setShowImage] = useState(false)
const [showVideo, setShowVideo] = useState(false)
useEffect(() => { useEffect(() => {
if (me?.privates?.imgproxyOnly && tab !== 'preview') return if (me?.privates?.imgproxyOnly && tab !== 'preview') return
@ -29,10 +30,15 @@ function ImageOriginal ({ src, topLevel, rel, tab, children, onClick, ...props }
const img = new window.Image() const img = new window.Image()
img.onload = () => setShowImage(true) img.onload = () => setShowImage(true)
img.src = src img.src = src
const video = document.createElement('video')
video.onloadeddata = () => setShowVideo(true)
video.src = src
return () => { return () => {
img.onload = null img.onload = null
img.src = '' img.src = ''
video.onloadeddata = null
video.src = ''
} }
}, [src, showImage]) }, [src, showImage])
@ -45,6 +51,8 @@ function ImageOriginal ({ src, topLevel, rel, tab, children, onClick, ...props }
onError={() => setShowImage(false)} onError={() => setShowImage(false)}
/> />
) )
} else if (showVideo) {
return <video src={src} controls />
} else { } else {
// user is not okay with loading original url automatically or there was an error loading the image // user is not okay with loading original url automatically or there was an error loading the image

View File

@ -16,6 +16,7 @@ import { useMe } from './me'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
import { UPSERT_LINK } from '@/fragments/paidAction' import { UPSERT_LINK } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit' import useItemSubmit from './use-item-submit'
import useDebounceCallback from './use-debounce-callback'
export function LinkForm ({ item, sub, editThreshold, children }) { export function LinkForm ({ item, sub, editThreshold, children }) {
const router = useRouter() const router = useRouter()
@ -25,6 +26,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
// if Web Share Target API was used // if Web Share Target API was used
const shareUrl = router.query.url const shareUrl = router.query.url
const shareTitle = router.query.title const shareTitle = router.query.title
// allows finer control over dupe accordian layout shift
const [dupes, setDupes] = useState()
const [getPageTitleAndUnshorted, { data }] = useLazyQuery(gql` const [getPageTitleAndUnshorted, { data }] = useLazyQuery(gql`
query PageTitleAndUnshorted($url: String!) { query PageTitleAndUnshorted($url: String!) {
@ -39,9 +42,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
dupes(url: $url) { dupes(url: $url) {
...ItemFields ...ItemFields
} }
}`, { }`)
onCompleted: () => setPostDisabled(false)
})
const [getRelated, { data: relatedData }] = useLazyQuery(gql` const [getRelated, { data: relatedData }] = useLazyQuery(gql`
${ITEM_FIELDS} ${ITEM_FIELDS}
query related($title: String!) { query related($title: String!) {
@ -69,19 +70,27 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
const onSubmit = useItemSubmit(UPSERT_LINK, { item, sub }) const onSubmit = useItemSubmit(UPSERT_LINK, { item, sub })
const getDupesDebounce = useDebounceCallback((...args) => getDupes(...args), 1000, [getDupes])
useEffect(() => { useEffect(() => {
if (data?.pageTitleAndUnshorted?.title) { if (data?.pageTitleAndUnshorted?.title) {
setTitleOverride(data.pageTitleAndUnshorted.title) setTitleOverride(data.pageTitleAndUnshorted.title)
} }
}, [data?.pageTitleAndUnshorted?.title]) }, [data?.pageTitleAndUnshorted?.title])
useEffect(() => {
if (!dupesLoading) {
setDupes(dupesData?.dupes)
}
}, [dupesLoading, dupesData, setDupes])
useEffect(() => { useEffect(() => {
if (data?.pageTitleAndUnshorted?.unshorted) { if (data?.pageTitleAndUnshorted?.unshorted) {
getDupes({ getDupesDebounce({
variables: { url: data?.pageTitleAndUnshorted?.unshorted } variables: { url: data?.pageTitleAndUnshorted?.unshorted }
}) })
} }
}, [data?.pageTitleAndUnshorted?.unshorted]) }, [data?.pageTitleAndUnshorted?.unshorted, getDupesDebounce])
const [postDisabled, setPostDisabled] = useState(false) const [postDisabled, setPostDisabled] = useState(false)
const [titleOverride, setTitleOverride] = useState() const [titleOverride, setTitleOverride] = useState()
@ -147,8 +156,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
} }
if (e.target.value) { if (e.target.value) {
setPostDisabled(true) setPostDisabled(true)
setTimeout(() => setPostDisabled(false), 3000) setTimeout(() => setPostDisabled(false), 2000)
getDupes({ getDupesDebounce({
variables: { url: e.target.value } variables: { url: e.target.value }
}) })
} }
@ -164,15 +173,15 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
/> />
</AdvPostForm> </AdvPostForm>
<ItemButtonBar itemId={item?.id} disable={postDisabled}> <ItemButtonBar itemId={item?.id} disable={postDisabled}>
{!item && dupesLoading && {!item && postDisabled &&
<div className='d-flex justify-content-center'> <div className='d-flex align-items-center small'>
<Moon className='spin fill-grey' /> <Moon className='spin fill-grey' height={16} width={16} />
<div className='ms-2 text-muted' style={{ fontWeight: '600' }}>searching for dupes</div> <div className='ms-2 text-muted'>searching for dupes</div>
</div>} </div>}
</ItemButtonBar> </ItemButtonBar>
{!item && {!item &&
<> <>
{dupesData?.dupes?.length > 0 && {dupes?.length > 0 &&
<div className='mt-3'> <div className='mt-3'>
<AccordianItem <AccordianItem
show show
@ -180,7 +189,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>dupes</div>} header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>dupes</div>}
body={ body={
<div> <div>
{dupesData.dupes.map((item, i) => ( {dupes.map((item, i) => (
<Item item={item} key={item.id} /> <Item item={item} key={item.id} />
))} ))}
</div> </div>

View File

@ -128,21 +128,24 @@
margin-bottom: .5rem; margin-bottom: .5rem;
} }
.text img { .text img, .text video {
display: block; display: block;
margin-top: .5rem; margin-top: .5rem;
margin-bottom: .5rem; margin-bottom: .5rem;
max-width: 100%; max-width: 100%;
cursor: zoom-in;
height: auto; height: auto;
max-height: 25vh; max-height: 25vh;
object-fit: contain;
object-position: left top;
min-width: 50%; min-width: 50%;
aspect-ratio: var(--aspect-ratio); aspect-ratio: var(--aspect-ratio);
} }
.text img.topLevel { .text img {
cursor: zoom-in;
object-fit: contain;
object-position: left top;
}
.text img.topLevel, .text video.topLevel {
margin-top: .75rem; margin-top: .75rem;
margin-bottom: .75rem; margin-bottom: .75rem;
max-height: 35vh; max-height: 35vh;

View File

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "Item_url_idx" ON "Item" ("url" COLLATE "C");

View File

@ -529,6 +529,7 @@ model Item {
@@index([invoiceId]) @@index([invoiceId])
@@index([invoiceActionState]) @@index([invoiceActionState])
@@index([cost]) @@index([cost])
@@index([url])
} }
// we use this to denormalize a user's aggregated interactions (zaps) with an item // we use this to denormalize a user's aggregated interactions (zaps) with an item