2024-09-06 14:34:44 +00:00
|
|
|
import styles from './text.module.css'
|
2024-09-07 17:07:10 +00:00
|
|
|
import { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react'
|
|
|
|
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP, parseEmbedUrl } from '@/lib/url'
|
2024-09-06 14:34:44 +00:00
|
|
|
import { useShowModal } from './modal'
|
|
|
|
import { useMe } from './me'
|
2024-09-07 17:07:10 +00:00
|
|
|
import { Button, Dropdown } from 'react-bootstrap'
|
2024-09-06 14:34:44 +00:00
|
|
|
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
|
|
|
import classNames from 'classnames'
|
2024-09-07 17:07:10 +00:00
|
|
|
import { TwitterTweetEmbed } from 'react-twitter-embed'
|
|
|
|
import YouTube from 'react-youtube'
|
|
|
|
import useDarkMode from './dark-mode'
|
2024-09-06 14:34:44 +00:00
|
|
|
|
|
|
|
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>
|
|
|
|
)
|
|
|
|
})
|
|
|
|
|
2024-09-07 17:07:10 +00:00
|
|
|
export default function MediaOrLink ({ linkFallback = true, ...props }) {
|
2024-09-06 14:34:44 +00:00
|
|
|
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
|
|
|
|
|
2024-09-07 17:07:10 +00:00
|
|
|
if (!error) {
|
|
|
|
if (media.image || media.video) {
|
|
|
|
return (
|
|
|
|
<Media
|
|
|
|
{...media} onClick={handleClick} onError={handleError}
|
|
|
|
/>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (media.embed) {
|
|
|
|
return (
|
|
|
|
<Embed
|
2024-09-14 01:40:40 +00:00
|
|
|
{...media.embed} topLevel={props.topLevel} src={media.src} onError={handleError}
|
2024-09-07 17:07:10 +00:00
|
|
|
/>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (linkFallback) {
|
|
|
|
return <LinkRaw {...props} />
|
2024-09-06 14:34:44 +00:00
|
|
|
}
|
|
|
|
|
2024-09-07 17:07:10 +00:00
|
|
|
return null
|
2024-09-06 14:34:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// determines how the media should be displayed given the params, me settings, and editor tab
|
2024-09-07 17:07:10 +00:00
|
|
|
export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) => {
|
2024-09-12 18:05:11 +00:00
|
|
|
const { me } = useMe()
|
2024-09-07 17:07:10 +00:00
|
|
|
const trusted = useMemo(() => !!srcSetIntital || IMGPROXY_URL_REGEXP.test(src) || MEDIA_DOMAIN_REGEXP.test(src), [!!srcSetIntital, src])
|
|
|
|
const { dimensions, video, format, ...srcSetObj } = srcSetIntital || {}
|
2024-09-13 14:26:08 +00:00
|
|
|
const [isImage, setIsImage] = useState(video === false && trusted)
|
2024-09-06 14:34:44 +00:00
|
|
|
const [isVideo, setIsVideo] = useState(video)
|
|
|
|
const showMedia = useMemo(() => tab === 'preview' || me?.privates?.showImagesAndVideos !== false, [tab, me?.privates?.showImagesAndVideos])
|
2024-09-07 17:07:10 +00:00
|
|
|
const embed = useMemo(() => parseEmbedUrl(src), [src])
|
2024-09-06 14:34:44 +00:00
|
|
|
|
|
|
|
useEffect(() => {
|
2024-09-07 17:07:10 +00:00
|
|
|
// don't load the video at all if user doesn't want these
|
|
|
|
if (!showMedia || isVideo || isImage || embed) return
|
2024-09-06 14:34:44 +00:00
|
|
|
// make sure it's not a false negative by trying to load URL as <img>
|
|
|
|
const img = new window.Image()
|
|
|
|
img.onload = () => setIsImage(true)
|
2024-09-13 14:26:08 +00:00
|
|
|
img.onerror = () => setIsImage(false)
|
2024-09-06 14:34:44 +00:00
|
|
|
img.src = src
|
|
|
|
const video = document.createElement('video')
|
|
|
|
video.onloadeddata = () => setIsVideo(true)
|
2024-09-13 14:26:08 +00:00
|
|
|
video.onerror = () => setIsVideo(false)
|
2024-09-06 14:34:44 +00:00
|
|
|
video.src = src
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
img.onload = null
|
2024-09-13 14:26:08 +00:00
|
|
|
img.onerror = null
|
2024-09-06 14:34:44 +00:00
|
|
|
img.src = ''
|
|
|
|
video.onloadeddata = null
|
2024-09-13 14:26:08 +00:00
|
|
|
video.onerror = null
|
2024-09-06 14:34:44 +00:00
|
|
|
video.src = ''
|
|
|
|
}
|
2024-09-07 17:07:10 +00:00
|
|
|
}, [src, setIsImage, setIsVideo, showMedia, isVideo, embed])
|
2024-09-06 14:34:44 +00:00
|
|
|
|
|
|
|
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,
|
2024-09-07 17:07:10 +00:00
|
|
|
image: (!me?.privates?.imgproxyOnly || trusted) && showMedia && isImage && !isVideo && !embed,
|
|
|
|
video: !me?.privates?.imgproxyOnly && showMedia && isVideo && !embed,
|
|
|
|
embed: !me?.privates?.imgproxyOnly && showMedia && embed
|
2024-09-06 14:34:44 +00:00
|
|
|
}
|
|
|
|
}
|
2024-09-07 17:07:10 +00:00
|
|
|
|
|
|
|
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>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2024-09-08 00:45:37 +00:00
|
|
|
export const NostrEmbed = memo(function NostrEmbed ({ src, className, topLevel, id }) {
|
|
|
|
const [show, setShow] = useState(false)
|
|
|
|
const iframeRef = useRef(null)
|
|
|
|
|
2024-09-07 17:07:10 +00:00
|
|
|
useEffect(() => {
|
2024-09-08 15:32:35 +00:00
|
|
|
if (!iframeRef.current) return
|
|
|
|
|
2024-09-07 17:07:10 +00:00
|
|
|
const setHeightFromIframe = (e) => {
|
2024-09-08 00:45:37 +00:00
|
|
|
if (e.origin !== 'https://njump.me' || !e?.data?.height || e.source !== iframeRef.current.contentWindow) return
|
|
|
|
iframeRef.current.height = `${e.data.height}px`
|
2024-09-07 17:07:10 +00:00
|
|
|
}
|
2024-09-08 15:32:35 +00:00
|
|
|
|
2024-09-07 17:07:10 +00:00
|
|
|
window?.addEventListener('message', setHeightFromIframe)
|
2024-09-08 15:32:35 +00:00
|
|
|
|
|
|
|
// https://github.com/vercel/next.js/issues/39451
|
|
|
|
iframeRef.current.src = `https://njump.me/${id}?embed=yes`
|
|
|
|
|
2024-09-07 17:07:10 +00:00
|
|
|
return () => {
|
|
|
|
window?.removeEventListener('message', setHeightFromIframe)
|
|
|
|
}
|
2024-09-08 00:45:37 +00:00
|
|
|
}, [iframeRef.current])
|
2024-09-07 17:07:10 +00:00
|
|
|
|
|
|
|
return (
|
|
|
|
<div className={classNames(styles.nostrContainer, !show && styles.twitterContained, className)}>
|
|
|
|
<iframe
|
|
|
|
ref={iframeRef}
|
|
|
|
width={topLevel ? '550px' : '350px'}
|
|
|
|
style={{ maxWidth: '100%' }}
|
2024-09-08 00:45:37 +00:00
|
|
|
height={iframeRef.current?.height || (topLevel ? '200px' : '150px')}
|
2024-09-07 17:07:10 +00:00
|
|
|
frameBorder='0'
|
2024-09-08 15:32:35 +00:00
|
|
|
sandbox='allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox'
|
2024-09-07 17:07:10 +00:00
|
|
|
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>
|
|
|
|
)
|
|
|
|
})
|
|
|
|
|
2024-09-08 15:32:35 +00:00
|
|
|
const SpotifyEmbed = function SpotifyEmbed ({ src, className }) {
|
|
|
|
const iframeRef = useRef(null)
|
|
|
|
|
|
|
|
// 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+\//, '/')
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (!iframeRef.current) return
|
|
|
|
|
|
|
|
const id = url.pathname.split('/').pop()
|
|
|
|
|
|
|
|
// https://developer.spotify.com/documentation/embeds/tutorials/using-the-iframe-api
|
|
|
|
window.onSpotifyIframeApiReady = (IFrameAPI) => {
|
|
|
|
const options = {
|
|
|
|
uri: `spotify:episode:${id}`
|
|
|
|
}
|
|
|
|
const callback = (EmbedController) => {}
|
|
|
|
IFrameAPI.createController(iframeRef.current, options, callback)
|
|
|
|
}
|
|
|
|
|
|
|
|
return () => { window.onSpotifyIframeApiReady = null }
|
|
|
|
}, [iframeRef.current, url.pathname])
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className={classNames(styles.spotifyWrapper, className)}>
|
|
|
|
<iframe
|
|
|
|
ref={iframeRef}
|
|
|
|
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 allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-presentation'
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2024-09-07 17:07:10 +00:00
|
|
|
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'
|
2024-09-08 15:32:35 +00:00
|
|
|
sandbox='allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-same-origin'
|
2024-09-07 17:07:10 +00:00
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (provider === 'spotify') {
|
|
|
|
return (
|
2024-09-08 15:32:35 +00:00
|
|
|
<SpotifyEmbed src={src} className={className} />
|
2024-09-07 17:07:10 +00:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
})
|