refactor embeds to be reused (#1368)
* refactor embeds to be reused * adjust the meaning of settings for embeds * add wavlake embed (close #1359) * add spotify embed (closes #1360) * fix 'format' appearing in srcSet * add nostr embed * refine nostr embed * Update components/media-or-link.js Co-authored-by: ekzyis <ek@stacker.news> * Update pages/settings/index.js Co-authored-by: ekzyis <ek@stacker.news> * ek suggestions --------- Co-authored-by: ekzyis <ek@stacker.news>
This commit is contained in:
parent
f05b29717a
commit
15b038cd78
|
@ -9,10 +9,7 @@ import styles from '@/styles/item.module.css'
|
|||
import itemStyles from './item.module.css'
|
||||
import { useMe } from './me'
|
||||
import Button from 'react-bootstrap/Button'
|
||||
import { TwitterTweetEmbed } from 'react-twitter-embed'
|
||||
import YouTube from 'react-youtube'
|
||||
import useDarkMode from './dark-mode'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import Poll from './poll'
|
||||
import { commentsViewed } from '@/lib/new-comments'
|
||||
import Related from './related'
|
||||
|
@ -22,7 +19,7 @@ import Share from './share'
|
|||
import Toc from './table-of-contents'
|
||||
import Link from 'next/link'
|
||||
import { RootProvider } from './root'
|
||||
import { IMGPROXY_URL_REGEXP, parseEmbedUrl } from '@/lib/url'
|
||||
import { decodeProxyUrl, IMGPROXY_URL_REGEXP } from '@/lib/url'
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
import { useQuoteReply } from './use-quote-reply'
|
||||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||
|
@ -50,91 +47,11 @@ function BioItem ({ item, handleClick }) {
|
|||
)
|
||||
}
|
||||
|
||||
function TweetSkeleton () {
|
||||
return (
|
||||
<div className={styles.tweetsSkeleton}>
|
||||
<div className={styles.tweetSkeleton}>
|
||||
<div className={`${styles.img} clouds`} />
|
||||
<div className={styles.content1}>
|
||||
<div className={`${styles.line} clouds`} />
|
||||
<div className={`${styles.line} clouds`} />
|
||||
<div className={`${styles.line} clouds`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function ItemEmbed ({ url, imgproxyUrls }) {
|
||||
const src = IMGPROXY_URL_REGEXP.test(url) ? decodeProxyUrl(url) : url
|
||||
const srcSet = imgproxyUrls?.[url]
|
||||
|
||||
function ItemEmbed ({ item }) {
|
||||
const [darkMode] = useDarkMode()
|
||||
const [overflowing, setOverflowing] = useState(false)
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
// This Twitter embed could use similar logic to the video embeds below
|
||||
const twitter = item.url?.match(/^https?:\/\/(?:twitter|x)\.com\/(?:#!\/)?\w+\/status(?:es)?\/(?<id>\d+)/)
|
||||
if (twitter?.groups?.id) {
|
||||
return (
|
||||
<div className={`${styles.twitterContainer} ${show ? '' : styles.twitterContained}`}>
|
||||
<TwitterTweetEmbed tweetId={twitter.groups.id} options={{ theme: darkMode ? 'dark' : 'light', width: '550px' }} key={darkMode ? '1' : '2'} placeholder={<TweetSkeleton />} onLoad={() => setOverflowing(true)} />
|
||||
{overflowing && !show &&
|
||||
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
|
||||
show full tweet
|
||||
</Button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { provider, id, meta } = parseEmbedUrl(item.url)
|
||||
|
||||
if (provider === 'youtube') {
|
||||
return (
|
||||
<div className={styles.videoWrapper}>
|
||||
<YouTube
|
||||
videoId={id} className={styles.videoContainer} opts={{
|
||||
playerVars: {
|
||||
start: meta?.start || 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'rumble') {
|
||||
return (
|
||||
<div className={styles.videoWrapper}>
|
||||
<div className={styles.videoContainer}>
|
||||
<iframe
|
||||
title='Rumble Video'
|
||||
allowFullScreen
|
||||
src={meta?.href}
|
||||
sandbox='allow-scripts'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'peertube') {
|
||||
return (
|
||||
<div className={styles.videoWrapper}>
|
||||
<div className={styles.videoContainer}>
|
||||
<iframe
|
||||
title='PeerTube Video'
|
||||
allowFullScreen
|
||||
src={meta?.href}
|
||||
sandbox='allow-scripts'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.url?.match(IMGPROXY_URL_REGEXP)) {
|
||||
return <MediaOrLink src={item.url} rel={item.rel ?? UNKNOWN_LINK_REL} />
|
||||
}
|
||||
|
||||
return null
|
||||
return <MediaOrLink src={src} srcSet={srcSet} topLevel linkFallback={false} />
|
||||
}
|
||||
|
||||
function FwdUsers ({ forwards }) {
|
||||
|
@ -174,7 +91,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
|
|||
>
|
||||
<article className={styles.fullItemContainer} ref={textRef}>
|
||||
{item.text && <ItemText item={item} />}
|
||||
{item.url && <ItemEmbed item={item} />}
|
||||
{item.url && !item.outlawed && <ItemEmbed url={item.url} imgproxyUrls={item.imgproxyUrls} />}
|
||||
{item.poll && <Poll item={item} />}
|
||||
{item.bounty &&
|
||||
<div className='fw-bold mt-2'>
|
||||
|
|
|
@ -9,6 +9,7 @@ import PollIcon from '@/svgs/bar-chart-horizontal-fill.svg'
|
|||
import BountyIcon from '@/svgs/bounty-bag.svg'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
import ImageIcon from '@/svgs/image-fill.svg'
|
||||
import VideoIcon from '@/svgs/video-on-fill.svg'
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
import ItemInfo from './item-info'
|
||||
import Prism from '@/svgs/prism.svg'
|
||||
|
@ -20,6 +21,9 @@ import { DownZap } from './dont-link-this'
|
|||
import { timeLeft } from '@/lib/time'
|
||||
import classNames from 'classnames'
|
||||
import removeMd from 'remove-markdown'
|
||||
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, parseInternalLinks } from '@/lib/url'
|
||||
import ItemPopover from './item-popover'
|
||||
import { useMe } from './me'
|
||||
|
||||
function onItemClick (e, router, item) {
|
||||
const viewedAt = commentsViewedAt(item)
|
||||
|
@ -45,6 +49,41 @@ export function SearchTitle ({ title }) {
|
|||
})
|
||||
}
|
||||
|
||||
function mediaType ({ url, imgproxyUrls }) {
|
||||
const me = useMe()
|
||||
const src = IMGPROXY_URL_REGEXP.test(url) ? decodeProxyUrl(url) : url
|
||||
if (!imgproxyUrls?.[src] ||
|
||||
me?.privates?.showImagesAndVideos === false ||
|
||||
// we don't proxy videos even if we have thumbnails
|
||||
(me?.privates?.imgproxyOnly && imgproxyUrls?.[src]?.video)) return
|
||||
return imgproxyUrls?.[src]?.video ? 'video' : 'image'
|
||||
}
|
||||
|
||||
function ItemLink ({ url, rel }) {
|
||||
try {
|
||||
const { linkText } = parseInternalLinks(url)
|
||||
if (linkText) {
|
||||
return (
|
||||
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
||||
<Link href={url} className={styles.link}>{linkText}</Link>
|
||||
</ItemPopover>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line
|
||||
<a
|
||||
className={styles.link} target='_blank' href={url}
|
||||
rel={rel ?? UNKNOWN_LINK_REL}
|
||||
>
|
||||
{url.replace(/(^https?:|^)\/\//, '')}
|
||||
</a>
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default function Item ({
|
||||
item, rank, belowTitle, right, full, children, itemClassName,
|
||||
onQuoteReply, pinnable
|
||||
|
@ -52,7 +91,8 @@ export default function Item ({
|
|||
const titleRef = useRef()
|
||||
const router = useRouter()
|
||||
|
||||
const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL)
|
||||
const media = mediaType({ url: item.url, imgproxyUrls: item.imgproxyUrls })
|
||||
const MediaIcon = media === 'video' ? VideoIcon : ImageIcon
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -87,16 +127,9 @@ export default function Item ({
|
|||
</ActionTooltip>
|
||||
</span>}
|
||||
{item.forwards?.length > 0 && <span className={styles.icon}><Prism className='fill-grey ms-1' height={14} width={14} /></span>}
|
||||
{image && <span className={styles.icon}><ImageIcon className='fill-grey ms-2' height={16} width={16} /></span>}
|
||||
{media && <span className={styles.icon}><MediaIcon className='fill-grey ms-2' height={16} width={16} /></span>}
|
||||
</Link>
|
||||
{item.url && !image &&
|
||||
// eslint-disable-next-line
|
||||
<a
|
||||
className={styles.link} target='_blank' href={item.url}
|
||||
rel={item.rel ?? UNKNOWN_LINK_REL}
|
||||
>
|
||||
{item.url.replace(/(^https?:|^)\/\//, '')}
|
||||
</a>}
|
||||
{item.url && !media && <ItemLink url={item.url} rel={UNKNOWN_LINK_REL} />}
|
||||
</div>
|
||||
<ItemInfo
|
||||
full={full} item={item}
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
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 { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react'
|
||||
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP, parseEmbedUrl } from '@/lib/url'
|
||||
import { useShowModal } from './modal'
|
||||
import { useMe } from './me'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { Button, Dropdown } from 'react-bootstrap'
|
||||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||
import classNames from 'classnames'
|
||||
import { TwitterTweetEmbed } from 'react-twitter-embed'
|
||||
import YouTube from 'react-youtube'
|
||||
import useDarkMode from './dark-mode'
|
||||
|
||||
function LinkRaw ({ href, children, src, rel }) {
|
||||
const isRawURL = /^https?:\/\//.test(children?.[0])
|
||||
|
@ -46,7 +49,7 @@ const Media = memo(function Media ({ src, bestResSrc, srcSet, sizes, width, heig
|
|||
)
|
||||
})
|
||||
|
||||
export default function MediaOrLink (props) {
|
||||
export default function MediaOrLink ({ linkFallback = true, ...props }) {
|
||||
const media = useMediaHelper(props)
|
||||
const [error, setError] = useState(false)
|
||||
const showModal = useShowModal()
|
||||
|
@ -78,28 +81,45 @@ export default function MediaOrLink (props) {
|
|||
|
||||
if (!media.src) return null
|
||||
|
||||
if (!error && (media.image || media.video)) {
|
||||
return (
|
||||
<Media
|
||||
{...media} onClick={handleClick} onError={handleError}
|
||||
/>
|
||||
)
|
||||
if (!error) {
|
||||
if (media.image || media.video) {
|
||||
return (
|
||||
<Media
|
||||
{...media} onClick={handleClick} onError={handleError}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (media.embed) {
|
||||
return (
|
||||
<Embed
|
||||
{...media.embed} src={media.src}
|
||||
className={media.className} onError={handleError} topLevel={props.topLevel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return <LinkRaw {...props} />
|
||||
if (linkFallback) {
|
||||
return <LinkRaw {...props} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// determines how the media should be displayed given the params, me settings, and editor tab
|
||||
const useMediaHelper = ({ src, srcSet: { dimensions, video, ...srcSetObj } = {}, topLevel, tab }) => {
|
||||
export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) => {
|
||||
const me = useMe()
|
||||
const trusted = useMemo(() => !!srcSetObj || IMGPROXY_URL_REGEXP.test(src) || MEDIA_DOMAIN_REGEXP.test(src), [!!srcSetObj, src])
|
||||
const trusted = useMemo(() => !!srcSetIntital || IMGPROXY_URL_REGEXP.test(src) || MEDIA_DOMAIN_REGEXP.test(src), [!!srcSetIntital, src])
|
||||
const { dimensions, video, format, ...srcSetObj } = srcSetIntital || {}
|
||||
const [isImage, setIsImage] = useState(!video && trusted)
|
||||
const [isVideo, setIsVideo] = useState(video)
|
||||
const showMedia = useMemo(() => tab === 'preview' || me?.privates?.showImagesAndVideos !== false, [tab, me?.privates?.showImagesAndVideos])
|
||||
const embed = useMemo(() => parseEmbedUrl(src), [src])
|
||||
|
||||
useEffect(() => {
|
||||
// don't load the video at all if use doesn't want these
|
||||
if (!showMedia || isVideo || isImage) return
|
||||
// don't load the video at all if user doesn't want these
|
||||
if (!showMedia || isVideo || isImage || embed) 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)
|
||||
|
@ -114,7 +134,7 @@ const useMediaHelper = ({ src, srcSet: { dimensions, video, ...srcSetObj } = {},
|
|||
video.onloadeddata = null
|
||||
video.src = ''
|
||||
}
|
||||
}, [src, setIsImage, setIsVideo, showMedia, isVideo])
|
||||
}, [src, setIsImage, setIsVideo, showMedia, isVideo, embed])
|
||||
|
||||
const srcSet = useMemo(() => {
|
||||
if (Object.keys(srcSetObj).length === 0) return undefined
|
||||
|
@ -164,7 +184,177 @@ const useMediaHelper = ({ src, srcSet: { dimensions, video, ...srcSetObj } = {},
|
|||
width,
|
||||
height,
|
||||
className: classNames(topLevel && styles.topLevel),
|
||||
image: (!me?.privates?.imgproxyOnly || trusted) && showMedia && isImage && !isVideo,
|
||||
video: !me?.privates?.imgproxyOnly && showMedia && isVideo
|
||||
image: (!me?.privates?.imgproxyOnly || trusted) && showMedia && isImage && !isVideo && !embed,
|
||||
video: !me?.privates?.imgproxyOnly && showMedia && isVideo && !embed,
|
||||
embed: !me?.privates?.imgproxyOnly && showMedia && embed
|
||||
}
|
||||
}
|
||||
|
||||
function TweetSkeleton ({ className }) {
|
||||
return (
|
||||
<div className={classNames(styles.tweetsSkeleton, className)}>
|
||||
<div className={styles.tweetSkeleton}>
|
||||
<div className={`${styles.img} clouds`} />
|
||||
<div className={styles.content1}>
|
||||
<div className={`${styles.line} clouds`} />
|
||||
<div className={`${styles.line} clouds`} />
|
||||
<div className={`${styles.line} clouds`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const useFrameHeight = (
|
||||
iframeRef
|
||||
) => {
|
||||
const [height, setHeight] = useState(0)
|
||||
const iframeCurrent = iframeRef.current
|
||||
useEffect(() => {
|
||||
const setHeightFromIframe = (e) => {
|
||||
if (e.origin !== 'https://njump.me' || !e?.data?.height) return
|
||||
setHeight(e.data.height)
|
||||
}
|
||||
window?.addEventListener('message', setHeightFromIframe)
|
||||
return () => {
|
||||
window?.removeEventListener('message', setHeightFromIframe)
|
||||
}
|
||||
}, [iframeCurrent])
|
||||
return height
|
||||
}
|
||||
|
||||
export const NostrEmbed = memo(function NostrEmbed ({ src, className, topLevel, id }) {
|
||||
const [show, setShow] = useState(false)
|
||||
const iframeRef = useRef(null)
|
||||
const frameHeight = useFrameHeight(iframeRef)
|
||||
return (
|
||||
<div className={classNames(styles.nostrContainer, !show && styles.twitterContained, className)}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`https://njump.me/${id}?embed=yes`}
|
||||
width={topLevel ? '550px' : '350px'}
|
||||
style={{ maxWidth: '100%' }}
|
||||
height={frameHeight ? `${frameHeight}px` : topLevel ? '200px' : '150px'}
|
||||
frameBorder='0'
|
||||
sandbox='allow-scripts allow-same-origin'
|
||||
allow=''
|
||||
/>
|
||||
{!show &&
|
||||
<Button size='md' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
|
||||
<div>show full note</div>
|
||||
<small className='fw-normal fst-italic'>or other stuff</small>
|
||||
</Button>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const Embed = memo(function Embed ({ src, provider, id, meta, className, topLevel, onError }) {
|
||||
const [darkMode] = useDarkMode()
|
||||
const [overflowing, setOverflowing] = useState(true)
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
// This Twitter embed could use similar logic to the video embeds below
|
||||
if (provider === 'twitter') {
|
||||
return (
|
||||
<div className={classNames(styles.twitterContainer, !show && styles.twitterContained, className)}>
|
||||
<TwitterTweetEmbed
|
||||
tweetId={id}
|
||||
options={{ theme: darkMode ? 'dark' : 'light', width: topLevel ? '550px' : '350px' }}
|
||||
key={darkMode ? '1' : '2'}
|
||||
placeholder={<TweetSkeleton className={className} />}
|
||||
onLoad={() => setOverflowing(true)}
|
||||
/>
|
||||
{overflowing && !show &&
|
||||
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
|
||||
show full tweet
|
||||
</Button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'nostr') {
|
||||
return (
|
||||
<NostrEmbed src={src} className={className} topLevel={topLevel} id={id} />
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'wavlake') {
|
||||
return (
|
||||
<div className={classNames(styles.wavlakeWrapper, className)}>
|
||||
<iframe
|
||||
src={`https://embed.wavlake.com/track/${id}`} width='100%' height='380' frameBorder='0'
|
||||
allow='encrypted-media'
|
||||
sandbox='allow-scripts'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'spotify') {
|
||||
// https://open.spotify.com/track/1KFxcj3MZrpBGiGA8ZWriv?si=f024c3aa52294aa1
|
||||
// Remove any additional path segments
|
||||
const url = new URL(src)
|
||||
url.pathname = url.pathname.replace(/\/intl-\w+\//, '/')
|
||||
return (
|
||||
<div className={classNames(styles.spotifyWrapper, className)}>
|
||||
<iframe
|
||||
title='Spotify Web Player'
|
||||
src={`https://open.spotify.com/embed${url.pathname}`}
|
||||
width='100%'
|
||||
height='152'
|
||||
allowfullscreen=''
|
||||
frameBorder='0'
|
||||
allow='encrypted-media; clipboard-write;'
|
||||
style={{ borderRadius: '12px' }}
|
||||
sandbox='allow-scripts'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'youtube') {
|
||||
return (
|
||||
<div className={classNames(styles.videoWrapper, className)}>
|
||||
<YouTube
|
||||
videoId={id} className={styles.videoContainer} opts={{
|
||||
playerVars: {
|
||||
start: meta?.start || 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'rumble') {
|
||||
return (
|
||||
<div className={classNames(styles.videoWrapper, className)}>
|
||||
<div className={styles.videoContainer}>
|
||||
<iframe
|
||||
title='Rumble Video'
|
||||
allowFullScreen
|
||||
src={meta?.href}
|
||||
sandbox='allow-scripts'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'peertube') {
|
||||
return (
|
||||
<div className={classNames(styles.videoWrapper, className)}>
|
||||
<div className={styles.videoContainer}>
|
||||
<iframe
|
||||
title='PeerTube Video'
|
||||
allowFullScreen
|
||||
src={meta?.href}
|
||||
sandbox='allow-scripts'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import styles from './text.module.css'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import YouTube from 'react-youtube'
|
||||
import gfm from 'remark-gfm'
|
||||
import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import atomDark from 'react-syntax-highlighter/dist/cjs/styles/prism/atom-dark'
|
||||
|
@ -13,7 +12,7 @@ import Thumb from '@/svgs/thumb-up-fill.svg'
|
|||
import { toString } from 'mdast-util-to-string'
|
||||
import copy from 'clipboard-copy'
|
||||
import MediaOrLink from './media-or-link'
|
||||
import { IMGPROXY_URL_REGEXP, parseInternalLinks, parseEmbedUrl, decodeProxyUrl } from '@/lib/url'
|
||||
import { IMGPROXY_URL_REGEXP, parseInternalLinks, decodeProxyUrl } from '@/lib/url'
|
||||
import reactStringReplace from 'react-string-replace'
|
||||
import { rehypeInlineCodeProperty, rehypeStyler } from '@/lib/md'
|
||||
import { Button } from 'react-bootstrap'
|
||||
|
@ -255,64 +254,11 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||
// 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])
|
||||
}), [outlawed, rel, itemId, Code, P, Heading, Table, TextMediaOrLink])
|
||||
|
||||
const remarkPlugins = useMemo(() => [gfm, mention, sub], [])
|
||||
const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], [])
|
||||
|
|
|
@ -246,6 +246,28 @@ img.fullScreen {
|
|||
font-size: .85rem;
|
||||
}
|
||||
|
||||
|
||||
/* Utility classes used in rehype plugins in md.js */
|
||||
.subscript {
|
||||
vertical-align: sub;
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
.superscript {
|
||||
vertical-align: super;
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
.videoWrapper {
|
||||
max-width: 320px;
|
||||
padding-right: 15px;
|
||||
margin: '0.5rem 0',
|
||||
}
|
||||
|
||||
.videoWrapper.topLevel {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.videoContainer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
@ -262,14 +284,108 @@ img.fullScreen {
|
|||
left: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Utility classes used in rehype plugins in md.js */
|
||||
.subscript {
|
||||
vertical-align: sub;
|
||||
font-size: smaller;
|
||||
.twitterContainer, .nostrContainer {
|
||||
margin-top: .25rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.superscript {
|
||||
vertical-align: super;
|
||||
font-size: smaller;
|
||||
.twitterContainer:not(:first-child), .nostrContainer:not(:first-child) {
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.twitterContainer iframe, .nostrContainer iframe {
|
||||
border-radius: 13px;
|
||||
}
|
||||
|
||||
.nostrContainer {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.videoWrapper:not(:first-child) {
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.twitterContained {
|
||||
height: 150px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.twitterContained.topLevel {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.twitterShowFull {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-radius: 0;
|
||||
line-height: 1.2;
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
|
||||
.tweetsSkeleton {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
max-width: 350px;
|
||||
width: 100%;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.tweetsSkeleton.topLevel {
|
||||
max-width: 550px;
|
||||
}
|
||||
|
||||
.tweetSkeleton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tweetSkeleton {
|
||||
border: 0.05rem solid var(--theme-borderColor);
|
||||
border-radius: 12px;
|
||||
height: 150px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.topLevel .tweetSkeleton {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.tweetSkeleton .img {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tweetSkeleton .content1,
|
||||
.tweetSkeleton .content2 {
|
||||
height: 50%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tweetSkeleton .line {
|
||||
height: 25%;
|
||||
margin: 0.5rem 0;
|
||||
width: 100%;
|
||||
border-radius: .4rem;
|
||||
}
|
||||
|
||||
.tweetSkeleton .line:last-child {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.wavlakeWrapper {
|
||||
width: 100%;
|
||||
height: 380px;
|
||||
max-width: 480px;
|
||||
border-radius: 1.65rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spotifyWrapper {
|
||||
width: 100%;
|
||||
height: 152px;
|
||||
max-width: 480px;
|
||||
border-radius: 13px;
|
||||
overflow: hidden;
|
||||
}
|
|
@ -10,7 +10,7 @@ export const defaultCommentSort = (pinned, bio, createdAt) => {
|
|||
return 'hot'
|
||||
}
|
||||
|
||||
export const isJob = item => typeof item.maxBid !== 'undefined'
|
||||
export const isJob = item => item.maxBid !== null && typeof item.maxBid !== 'undefined'
|
||||
|
||||
// a delete directive preceded by a non word character that isn't a backtick
|
||||
const deletePattern = /\B@delete\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi
|
||||
|
|
53
lib/url.js
53
lib/url.js
|
@ -1,3 +1,6 @@
|
|||
import { nip19 } from 'nostr-tools'
|
||||
import { DEFAULT_CROSSPOSTING_RELAYS } from './nostr'
|
||||
|
||||
export function ensureProtocol (value) {
|
||||
if (!value) return value
|
||||
value = value.trim()
|
||||
|
@ -76,9 +79,52 @@ export function parseInternalLinks (href) {
|
|||
}
|
||||
|
||||
export function parseEmbedUrl (href) {
|
||||
if (!href) return null
|
||||
|
||||
try {
|
||||
const twitter = href.match(/^https?:\/\/(?:twitter|x)\.com\/(?:#!\/)?\w+\/status(?:es)?\/(?<id>\d+)/)
|
||||
if (twitter?.groups?.id) {
|
||||
return {
|
||||
provider: 'twitter',
|
||||
id: twitter.groups.id
|
||||
}
|
||||
}
|
||||
|
||||
const { hostname, pathname, searchParams } = new URL(href)
|
||||
|
||||
// nostr prefixes: [npub1, nevent1, nprofile1, note1]
|
||||
const nostr = href.match(/(?<id>(?<type>npub1|nevent1|nprofile1|note1|naddr1)[02-9ac-hj-np-z]+)/)
|
||||
if (nostr?.groups?.id) {
|
||||
let id = nostr.groups.id
|
||||
if (nostr.groups.type === 'npub1') {
|
||||
const { data } = nip19.decode(id)
|
||||
id = nip19.nprofileEncode({ pubkey: data })
|
||||
}
|
||||
if (nostr.groups.type === 'note1') {
|
||||
const { data } = nip19.decode(id)
|
||||
// njump needs relays to embed
|
||||
id = nip19.neventEncode({ id: data, relays: DEFAULT_CROSSPOSTING_RELAYS, author: '' })
|
||||
}
|
||||
return {
|
||||
provider: 'nostr',
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
// https://wavlake.com/track/c0aaeff8-5a26-49cf-8dad-2b6909e4aed1
|
||||
if (hostname.endsWith('wavlake.com') && pathname.startsWith('/track')) {
|
||||
return {
|
||||
provider: 'wavlake',
|
||||
id: pathname.split('/')?.[2]
|
||||
}
|
||||
}
|
||||
|
||||
if (hostname.endsWith('spotify.com') && pathname.startsWith('/track')) {
|
||||
return {
|
||||
provider: 'spotify'
|
||||
}
|
||||
}
|
||||
|
||||
if (hostname.endsWith('youtube.com')) {
|
||||
if (pathname.includes('/watch')) {
|
||||
return {
|
||||
|
@ -130,12 +176,11 @@ export function parseEmbedUrl (href) {
|
|||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
console.error('Error parsing embed URL:', err)
|
||||
}
|
||||
|
||||
// Important to return empty object as default
|
||||
return {}
|
||||
return null
|
||||
}
|
||||
|
||||
export function stripTrailingSlash (uri) {
|
||||
|
|
|
@ -95,7 +95,7 @@ export function middleware (request) {
|
|||
// unsafe-inline for styles is not ideal but okay if script-src is using nonces
|
||||
"style-src 'self' a.stacker.news 'unsafe-inline'",
|
||||
"manifest-src 'self'",
|
||||
'frame-src www.youtube.com platform.twitter.com rumble.com bitcointv.com peertube.tv',
|
||||
'frame-src www.youtube.com platform.twitter.com njump.me open.spotify.com rumble.com embed.wavlake.com bitcointv.com peertube.tv',
|
||||
"connect-src 'self' https: wss:" + devSrc,
|
||||
// disable dangerous plugins like Flash
|
||||
"object-src 'none'",
|
||||
|
|
|
@ -448,12 +448,12 @@ export default function Settings ({ ssrData }) {
|
|||
/>}
|
||||
<Checkbox
|
||||
label={
|
||||
<div className='d-flex align-items-center'>only load images from proxy
|
||||
<div className='d-flex align-items-center'>do not load images, videos, or content from external sites
|
||||
<Info>
|
||||
<ul className='fw-bold'>
|
||||
<li>only load images from our image proxy automatically</li>
|
||||
<ul>
|
||||
<li>only load images and videos when we can proxy them</li>
|
||||
<li>this prevents IP address leaks to arbitrary sites</li>
|
||||
<li>if we fail to load an image, the raw link will be shown</li>
|
||||
<li>if we can't, the raw link will be shown instead</li>
|
||||
</ul>
|
||||
</Info>
|
||||
</div>
|
||||
|
@ -511,9 +511,22 @@ export default function Settings ({ ssrData }) {
|
|||
/>
|
||||
<Checkbox
|
||||
label={
|
||||
<div className='d-flex align-items-center'>show images and video
|
||||
<div className='d-flex align-items-center'>show images, video, and 3rd party embeds
|
||||
<Info>
|
||||
<p>disable to show images and videos as links instead of embedding them</p>
|
||||
<ul>
|
||||
<li>if checked and a link is an image, video or can be embedded in another way, we will do it</li>
|
||||
<li>we support embeds from following sites:</li>
|
||||
<ul>
|
||||
<li>njump.me</li>
|
||||
<li>youtube.com</li>
|
||||
<li>twitter.com</li>
|
||||
<li>spotify.com</li>
|
||||
<li>rumble.com</li>
|
||||
<li>wavlake.com</li>
|
||||
<li>bitcointv.com</li>
|
||||
<li>peertube.tv</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
|
|
|
@ -15,98 +15,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.videoWrapper {
|
||||
max-width: 640px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.videoContainer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: 56.25%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.videoContainer iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.twitterContainer {
|
||||
margin-top: .25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.twitterContainer:not(:first-child) {
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.videoWrapper:not(:first-child) {
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.twitterContainer iframe {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.twitterContained {
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.twitterShowFull {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tweetsSkeleton {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
max-width: 550px;
|
||||
width: 100%;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.tweetSkeleton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tweetSkeleton {
|
||||
border: 0.05rem solid var(--theme-borderColor);
|
||||
border-radius: 12px;
|
||||
height: 200px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.tweetSkeleton .img {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tweetSkeleton .content1,
|
||||
.tweetSkeleton .content2 {
|
||||
height: 50%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tweetSkeleton .line {
|
||||
height: 25%;
|
||||
margin: 0.5rem 0;
|
||||
width: 100%;
|
||||
border-radius: .4rem;
|
||||
}
|
||||
|
||||
.tweetSkeleton .line:last-child {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.fullItemContainer {
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M17 9.2L22.2133 5.55071C22.4395 5.39235 22.7513 5.44737 22.9096 5.6736C22.9684 5.75764 23 5.85774 23 5.96033V18.0397C23 18.3158 22.7761 18.5397 22.5 18.5397C22.3974 18.5397 22.2973 18.5081 22.2133 18.4493L17 14.8V19C17 19.5523 16.5523 20 16 20H2C1.44772 20 1 19.5523 1 19V5C1 4.44772 1.44772 4 2 4H16C16.5523 4 17 4.44772 17 5V9.2Z"></path></svg>
|
After Width: | Height: | Size: 435 B |
Loading…
Reference in New Issue