Compare commits

...

3 Commits

Author SHA1 Message Date
Keyan 15b038cd78
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>
2024-09-07 12:07:10 -05:00
ekzyis f05b29717a
Fix grammar in autodelete invoices info (#1371) 2024-09-07 10:01:27 -05:00
ekzyis 54f8a61483
Random CSS fixes (#1370)
* Fix missing margin-left for invoice status in /satistics

* Fix margin-bottom not applied in invoice info

* Only apply margin-left if there is something left
2024-09-07 10:01:00 -05:00
14 changed files with 468 additions and 294 deletions

View File

@ -4,6 +4,7 @@ import { useAccordionButton } from 'react-bootstrap/AccordionButton'
import ArrowRight from '@/svgs/arrow-right-s-fill.svg'
import ArrowDown from '@/svgs/arrow-down-s-fill.svg'
import { useContext, useEffect, useState } from 'react'
import classNames from 'classnames'
const KEY_ID = '0'
@ -30,7 +31,7 @@ function ContextAwareToggle ({ children, headerColor = 'var(--theme-grey)', even
)
}
export default function AccordianItem ({ header, body, headerColor = 'var(--theme-grey)', show }) {
export default function AccordianItem ({ header, body, className, headerColor = 'var(--theme-grey)', show }) {
const [activeKey, setActiveKey] = useState()
useEffect(() => {
@ -44,7 +45,7 @@ export default function AccordianItem ({ header, body, headerColor = 'var(--them
return (
<Accordion defaultActiveKey={activeKey} activeKey={activeKey} onSelect={handleOnSelect}>
<ContextAwareToggle show={show} eventKey={KEY_ID} headerColor={headerColor}><div style={{ color: headerColor }}>{header}</div></ContextAwareToggle>
<Accordion.Collapse eventKey={KEY_ID} className='mt-2'>
<Accordion.Collapse eventKey={KEY_ID} className={classNames('mt-2', className)}>
<div>{body}</div>
</Accordion.Collapse>
</Accordion>

View File

@ -109,7 +109,8 @@ export default function Invoice ({ id, query = INVOICE, modal, onPayment, onCanc
<div className='w-100'>
<AccordianItem
header='sender information'
body={<PayerData data={lud18Data} className='text-muted ms-3 mb-3' />}
body={<PayerData data={lud18Data} className='text-muted ms-3' />}
className='mb-3'
/>
</div>}
{comment &&
@ -117,6 +118,7 @@ export default function Invoice ({ id, query = INVOICE, modal, onPayment, onCanc
<AccordianItem
header='sender comments'
body={<span className='text-muted ms-3'>{comment}</span>}
className='mb-3'
/>
</div>}
<Bolt11Info bolt11={bolt11} preimage={confirmedPreimage} />

View File

@ -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'>

View File

@ -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}

View File

@ -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,7 +81,8 @@ export default function MediaOrLink (props) {
if (!media.src) return null
if (!error && (media.image || media.video)) {
if (!error) {
if (media.image || media.video) {
return (
<Media
{...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 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
})

View File

@ -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], [])

View File

@ -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;
}

View File

@ -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

View File

@ -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) {

View File

@ -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'",

View File

@ -18,6 +18,7 @@ import PageLoading from '@/components/page-loading'
import PayerData from '@/components/payer-data'
import { Badge } from 'react-bootstrap'
import navStyles from '../settings/settings.module.css'
import classNames from 'classnames'
export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY, authRequired: true })
@ -36,7 +37,7 @@ function satusClass (status) {
}
}
function Satus ({ status }) {
function Satus ({ status, className }) {
if (!status) {
return null
}
@ -85,8 +86,8 @@ function Satus ({ status }) {
}
return (
<span className='d-inline-block'>
<Icon /><small className={`text-${color} fw-bold ms-2`}>{desc}</small>
<span className={classNames('d-inline-block', className)}>
<Icon /><small className={`text-${color} fw-bold ms-1`}>{desc}</small>
</span>
)
}
@ -141,7 +142,8 @@ function Detail ({ fact }) {
(fact.description && <span className='d-block'>{fact.description}</span>)}
<PayerData data={fact.invoicePayerData} className='text-muted' header />
{fact.invoiceComment && <small className='text-muted'><b>sender says:</b> {fact.invoiceComment}</small>}
<Satus status={fact.status} />{fact.autoWithdraw && <Badge className={styles.badge} bg={null}>{fact.type === 'p2p' ? 'p2p' : 'autowithdraw'}</Badge>}
<Satus className={fact.invoiceComment ? 'ms-1' : ''} status={fact.status} />
{fact.autoWithdraw && <Badge className={styles.badge} bg={null}>{fact.type === 'p2p' ? 'p2p' : 'autowithdraw'}</Badge>}
</Link>
</div>
)

View File

@ -352,7 +352,7 @@ export default function Settings ({ ssrData }) {
<li>use this to protect receiver privacy</li>
<li>applies retroactively, cannot be reversed</li>
<li>withdrawal invoices are kept at least {INVOICE_RETENTION_DAYS} days for security and debugging purposes</li>
<li>autodeletions are run a daily basis at night</li>
<li>autodeletions are run on a daily basis at night</li>
</ul>
</Info>
</div>
@ -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>
}

View File

@ -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;
}

1
svgs/video-on-fill.svg Normal file
View File

@ -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