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 itemStyles from './item.module.css'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import Button from 'react-bootstrap/Button'
|
import Button from 'react-bootstrap/Button'
|
||||||
import { TwitterTweetEmbed } from 'react-twitter-embed'
|
import { useEffect } from 'react'
|
||||||
import YouTube from 'react-youtube'
|
|
||||||
import useDarkMode from './dark-mode'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import Poll from './poll'
|
import Poll from './poll'
|
||||||
import { commentsViewed } from '@/lib/new-comments'
|
import { commentsViewed } from '@/lib/new-comments'
|
||||||
import Related from './related'
|
import Related from './related'
|
||||||
|
@ -22,7 +19,7 @@ import Share from './share'
|
||||||
import Toc from './table-of-contents'
|
import Toc from './table-of-contents'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { RootProvider } from './root'
|
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 { numWithUnits } from '@/lib/format'
|
||||||
import { useQuoteReply } from './use-quote-reply'
|
import { useQuoteReply } from './use-quote-reply'
|
||||||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||||
|
@ -50,91 +47,11 @@ function BioItem ({ item, handleClick }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TweetSkeleton () {
|
function ItemEmbed ({ url, imgproxyUrls }) {
|
||||||
return (
|
const src = IMGPROXY_URL_REGEXP.test(url) ? decodeProxyUrl(url) : url
|
||||||
<div className={styles.tweetsSkeleton}>
|
const srcSet = imgproxyUrls?.[url]
|
||||||
<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 ({ item }) {
|
return <MediaOrLink src={src} srcSet={srcSet} topLevel linkFallback={false} />
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FwdUsers ({ forwards }) {
|
function FwdUsers ({ forwards }) {
|
||||||
|
@ -174,7 +91,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
|
||||||
>
|
>
|
||||||
<article className={styles.fullItemContainer} ref={textRef}>
|
<article className={styles.fullItemContainer} ref={textRef}>
|
||||||
{item.text && <ItemText item={item} />}
|
{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.poll && <Poll item={item} />}
|
||||||
{item.bounty &&
|
{item.bounty &&
|
||||||
<div className='fw-bold mt-2'>
|
<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 BountyIcon from '@/svgs/bounty-bag.svg'
|
||||||
import ActionTooltip from './action-tooltip'
|
import ActionTooltip from './action-tooltip'
|
||||||
import ImageIcon from '@/svgs/image-fill.svg'
|
import ImageIcon from '@/svgs/image-fill.svg'
|
||||||
|
import VideoIcon from '@/svgs/video-on-fill.svg'
|
||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import ItemInfo from './item-info'
|
import ItemInfo from './item-info'
|
||||||
import Prism from '@/svgs/prism.svg'
|
import Prism from '@/svgs/prism.svg'
|
||||||
|
@ -20,6 +21,9 @@ import { DownZap } from './dont-link-this'
|
||||||
import { timeLeft } from '@/lib/time'
|
import { timeLeft } from '@/lib/time'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import removeMd from 'remove-markdown'
|
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) {
|
function onItemClick (e, router, item) {
|
||||||
const viewedAt = commentsViewedAt(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 ({
|
export default function Item ({
|
||||||
item, rank, belowTitle, right, full, children, itemClassName,
|
item, rank, belowTitle, right, full, children, itemClassName,
|
||||||
onQuoteReply, pinnable
|
onQuoteReply, pinnable
|
||||||
|
@ -52,7 +91,8 @@ export default function Item ({
|
||||||
const titleRef = useRef()
|
const titleRef = useRef()
|
||||||
const router = useRouter()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -87,16 +127,9 @@ export default function Item ({
|
||||||
</ActionTooltip>
|
</ActionTooltip>
|
||||||
</span>}
|
</span>}
|
||||||
{item.forwards?.length > 0 && <span className={styles.icon}><Prism className='fill-grey ms-1' height={14} width={14} /></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>
|
</Link>
|
||||||
{item.url && !image &&
|
{item.url && !media && <ItemLink url={item.url} rel={UNKNOWN_LINK_REL} />}
|
||||||
// eslint-disable-next-line
|
|
||||||
<a
|
|
||||||
className={styles.link} target='_blank' href={item.url}
|
|
||||||
rel={item.rel ?? UNKNOWN_LINK_REL}
|
|
||||||
>
|
|
||||||
{item.url.replace(/(^https?:|^)\/\//, '')}
|
|
||||||
</a>}
|
|
||||||
</div>
|
</div>
|
||||||
<ItemInfo
|
<ItemInfo
|
||||||
full={full} item={item}
|
full={full} item={item}
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
import styles from './text.module.css'
|
import styles from './text.module.css'
|
||||||
import { useState, useEffect, useMemo, useCallback, memo } from 'react'
|
import { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react'
|
||||||
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP } from '@/lib/url'
|
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP, parseEmbedUrl } from '@/lib/url'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { Dropdown } from 'react-bootstrap'
|
import { Button, Dropdown } from 'react-bootstrap'
|
||||||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||||
import classNames from 'classnames'
|
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 }) {
|
function LinkRaw ({ href, children, src, rel }) {
|
||||||
const isRawURL = /^https?:\/\//.test(children?.[0])
|
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 media = useMediaHelper(props)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
|
@ -78,7 +81,8 @@ export default function MediaOrLink (props) {
|
||||||
|
|
||||||
if (!media.src) return null
|
if (!media.src) return null
|
||||||
|
|
||||||
if (!error && (media.image || media.video)) {
|
if (!error) {
|
||||||
|
if (media.image || media.video) {
|
||||||
return (
|
return (
|
||||||
<Media
|
<Media
|
||||||
{...media} onClick={handleClick} onError={handleError}
|
{...media} onClick={handleClick} onError={handleError}
|
||||||
|
@ -86,20 +90,36 @@ export default function MediaOrLink (props) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (media.embed) {
|
||||||
|
return (
|
||||||
|
<Embed
|
||||||
|
{...media.embed} src={media.src}
|
||||||
|
className={media.className} onError={handleError} topLevel={props.topLevel}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkFallback) {
|
||||||
return <LinkRaw {...props} />
|
return <LinkRaw {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// determines how the media should be displayed given the params, me settings, and editor tab
|
// 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 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 [isImage, setIsImage] = useState(!video && trusted)
|
||||||
const [isVideo, setIsVideo] = useState(video)
|
const [isVideo, setIsVideo] = useState(video)
|
||||||
const showMedia = useMemo(() => tab === 'preview' || me?.privates?.showImagesAndVideos !== false, [tab, me?.privates?.showImagesAndVideos])
|
const showMedia = useMemo(() => tab === 'preview' || me?.privates?.showImagesAndVideos !== false, [tab, me?.privates?.showImagesAndVideos])
|
||||||
|
const embed = useMemo(() => parseEmbedUrl(src), [src])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// don't load the video at all if use doesn't want these
|
// don't load the video at all if user doesn't want these
|
||||||
if (!showMedia || isVideo || isImage) return
|
if (!showMedia || isVideo || isImage || embed) return
|
||||||
// make sure it's not a false negative by trying to load URL as <img>
|
// make sure it's not a false negative by trying to load URL as <img>
|
||||||
const img = new window.Image()
|
const img = new window.Image()
|
||||||
img.onload = () => setIsImage(true)
|
img.onload = () => setIsImage(true)
|
||||||
|
@ -114,7 +134,7 @@ const useMediaHelper = ({ src, srcSet: { dimensions, video, ...srcSetObj } = {},
|
||||||
video.onloadeddata = null
|
video.onloadeddata = null
|
||||||
video.src = ''
|
video.src = ''
|
||||||
}
|
}
|
||||||
}, [src, setIsImage, setIsVideo, showMedia, isVideo])
|
}, [src, setIsImage, setIsVideo, showMedia, isVideo, embed])
|
||||||
|
|
||||||
const srcSet = useMemo(() => {
|
const srcSet = useMemo(() => {
|
||||||
if (Object.keys(srcSetObj).length === 0) return undefined
|
if (Object.keys(srcSetObj).length === 0) return undefined
|
||||||
|
@ -164,7 +184,177 @@ const useMediaHelper = ({ src, srcSet: { dimensions, video, ...srcSetObj } = {},
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
className: classNames(topLevel && styles.topLevel),
|
className: classNames(topLevel && styles.topLevel),
|
||||||
image: (!me?.privates?.imgproxyOnly || trusted) && showMedia && isImage && !isVideo,
|
image: (!me?.privates?.imgproxyOnly || trusted) && showMedia && isImage && !isVideo && !embed,
|
||||||
video: !me?.privates?.imgproxyOnly && showMedia && isVideo
|
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 styles from './text.module.css'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import YouTube from 'react-youtube'
|
|
||||||
import gfm from 'remark-gfm'
|
import gfm from 'remark-gfm'
|
||||||
import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter'
|
import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||||
import atomDark from 'react-syntax-highlighter/dist/cjs/styles/prism/atom-dark'
|
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 { toString } from 'mdast-util-to-string'
|
||||||
import copy from 'clipboard-copy'
|
import copy from 'clipboard-copy'
|
||||||
import MediaOrLink from './media-or-link'
|
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 reactStringReplace from 'react-string-replace'
|
||||||
import { rehypeInlineCodeProperty, rehypeStyler } from '@/lib/md'
|
import { rehypeInlineCodeProperty, rehypeStyler } from '@/lib/md'
|
||||||
import { Button } from 'react-bootstrap'
|
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
|
// 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
|
// 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>
|
return <TextMediaOrLink src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</TextMediaOrLink>
|
||||||
},
|
},
|
||||||
img: 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 remarkPlugins = useMemo(() => [gfm, mention, sub], [])
|
||||||
const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], [])
|
const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], [])
|
||||||
|
|
|
@ -246,6 +246,28 @@ img.fullScreen {
|
||||||
font-size: .85rem;
|
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 {
|
.videoContainer {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -262,14 +284,108 @@ img.fullScreen {
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.twitterContainer, .nostrContainer {
|
||||||
/* Utility classes used in rehype plugins in md.js */
|
margin-top: .25rem;
|
||||||
.subscript {
|
position: relative;
|
||||||
vertical-align: sub;
|
overflow: hidden;
|
||||||
font-size: smaller;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.superscript {
|
.twitterContainer:not(:first-child), .nostrContainer:not(:first-child) {
|
||||||
vertical-align: super;
|
margin-top: .75rem;
|
||||||
font-size: smaller;
|
}
|
||||||
|
|
||||||
|
.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'
|
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
|
// 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
|
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) {
|
export function ensureProtocol (value) {
|
||||||
if (!value) return value
|
if (!value) return value
|
||||||
value = value.trim()
|
value = value.trim()
|
||||||
|
@ -76,9 +79,52 @@ export function parseInternalLinks (href) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseEmbedUrl (href) {
|
export function parseEmbedUrl (href) {
|
||||||
|
if (!href) return null
|
||||||
|
|
||||||
try {
|
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)
|
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 (hostname.endsWith('youtube.com')) {
|
||||||
if (pathname.includes('/watch')) {
|
if (pathname.includes('/watch')) {
|
||||||
return {
|
return {
|
||||||
|
@ -130,12 +176,11 @@ export function parseEmbedUrl (href) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore
|
console.error('Error parsing embed URL:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Important to return empty object as default
|
return null
|
||||||
return {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripTrailingSlash (uri) {
|
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
|
// unsafe-inline for styles is not ideal but okay if script-src is using nonces
|
||||||
"style-src 'self' a.stacker.news 'unsafe-inline'",
|
"style-src 'self' a.stacker.news 'unsafe-inline'",
|
||||||
"manifest-src 'self'",
|
"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,
|
"connect-src 'self' https: wss:" + devSrc,
|
||||||
// disable dangerous plugins like Flash
|
// disable dangerous plugins like Flash
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
|
|
|
@ -448,12 +448,12 @@ export default function Settings ({ ssrData }) {
|
||||||
/>}
|
/>}
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={
|
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>
|
<Info>
|
||||||
<ul className='fw-bold'>
|
<ul>
|
||||||
<li>only load images from our image proxy automatically</li>
|
<li>only load images and videos when we can proxy them</li>
|
||||||
<li>this prevents IP address leaks to arbitrary sites</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>
|
</ul>
|
||||||
</Info>
|
</Info>
|
||||||
</div>
|
</div>
|
||||||
|
@ -511,9 +511,22 @@ export default function Settings ({ ssrData }) {
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={
|
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>
|
<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>
|
</Info>
|
||||||
</div>
|
</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 {
|
.fullItemContainer {
|
||||||
margin-bottom: .5rem;
|
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