import styles from './text.module.css' 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 { 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]) return ( // eslint-disable-next-line {isRawURL || !children ? src : children} ) } const Media = memo(function Media ({ src, bestResSrc, srcSet, sizes, width, height, onClick, onError, style, className, video }) { return (
{video ?
) }) export default function MediaOrLink ({ linkFallback = true, ...props }) { const media = useMediaHelper(props) const [error, setError] = useState(false) const showModal = useShowModal() const handleClick = useCallback(() => showModal(close => { return (
) }, { fullScreen: true, overflow: ( open original ) }), [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) { if (media.image || media.video) { return ( ) } if (media.embed) { return ( ) } } if (linkFallback) { return } return null } // determines how the media should be displayed given the params, me settings, and editor tab export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) => { const { me } = useMe() 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 === false && 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 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 const img = new window.Image() img.onload = () => setIsImage(true) img.onerror = () => setIsImage(false) img.src = src const video = document.createElement('video') video.onloadeddata = () => setIsVideo(true) video.onerror = () => setIsVideo(false) video.src = src return () => { img.onload = null img.onerror = null img.src = '' video.onloadeddata = null video.onerror = null video.src = '' } }, [src, setIsImage, setIsVideo, showMedia, isVideo, embed]) const srcSet = useMemo(() => { if (Object.keys(srcSetObj).length === 0) return undefined // srcSetObj shape: { [widthDescriptor]: , ... } 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, 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 (
) } export const NostrEmbed = memo(function NostrEmbed ({ src, className, topLevel, id }) { const [show, setShow] = useState(false) const iframeRef = useRef(null) useEffect(() => { if (!iframeRef.current) return const setHeightFromIframe = (e) => { if (e.origin !== 'https://njump.me' || !e?.data?.height || e.source !== iframeRef.current.contentWindow) return iframeRef.current.height = `${e.data.height}px` } window?.addEventListener('message', setHeightFromIframe) // https://github.com/vercel/next.js/issues/39451 iframeRef.current.src = `https://njump.me/${id}?embed=yes` return () => { window?.removeEventListener('message', setHeightFromIframe) } }, [iframeRef.current]) return (