Compare commits
No commits in common. "2e1a7c103519b5e66fd7ab5d3966fff13c2db252" and "450c969dfc7535b41406b29d913404bdbc3295be" have entirely different histories.
2e1a7c1035
...
450c969dfc
@ -160,7 +160,7 @@ async function performPessimisticAction (actionType, args, context) {
|
|||||||
|
|
||||||
export async function retryPaidAction (actionType, args, context) {
|
export async function retryPaidAction (actionType, args, context) {
|
||||||
const { models, me } = context
|
const { models, me } = context
|
||||||
const { invoice: failedInvoice } = args
|
const { invoiceId } = args
|
||||||
|
|
||||||
console.log('retryPaidAction', actionType, args)
|
console.log('retryPaidAction', actionType, args)
|
||||||
|
|
||||||
@ -181,13 +181,18 @@ export async function retryPaidAction (actionType, args, context) {
|
|||||||
throw new Error(`retryPaidAction - action does not support retrying ${actionType}`)
|
throw new Error(`retryPaidAction - action does not support retrying ${actionType}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!failedInvoice) {
|
if (!invoiceId) {
|
||||||
throw new Error(`retryPaidAction - missing invoice ${actionType}`)
|
throw new Error(`retryPaidAction - missing invoiceId ${actionType}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.optimistic = true
|
context.optimistic = true
|
||||||
context.me = await models.user.findUnique({ where: { id: me.id } })
|
context.me = await models.user.findUnique({ where: { id: me.id } })
|
||||||
|
|
||||||
|
const failedInvoice = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
|
||||||
|
if (!failedInvoice) {
|
||||||
|
throw new Error(`retryPaidAction ${actionType} - invoice ${invoiceId} not found or not in failed state`)
|
||||||
|
}
|
||||||
|
|
||||||
const { msatsRequested, actionId } = failedInvoice
|
const { msatsRequested, actionId } = failedInvoice
|
||||||
context.cost = BigInt(msatsRequested)
|
context.cost = BigInt(msatsRequested)
|
||||||
context.actionId = actionId
|
context.actionId = actionId
|
||||||
@ -199,7 +204,7 @@ export async function retryPaidAction (actionType, args, context) {
|
|||||||
// update the old invoice to RETRYING, so that it's not confused with FAILED
|
// update the old invoice to RETRYING, so that it's not confused with FAILED
|
||||||
await tx.invoice.update({
|
await tx.invoice.update({
|
||||||
where: {
|
where: {
|
||||||
id: failedInvoice.id,
|
id: invoiceId,
|
||||||
actionState: 'FAILED'
|
actionState: 'FAILED'
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
@ -211,7 +216,7 @@ export async function retryPaidAction (actionType, args, context) {
|
|||||||
const invoice = await createDbInvoice(actionType, args, context, invoiceArgs)
|
const invoice = await createDbInvoice(actionType, args, context, invoiceArgs)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
|
result: await action.retry({ invoiceId, newInvoiceId: invoice.id }, context),
|
||||||
invoice,
|
invoice,
|
||||||
paymentMethod: 'OPTIMISTIC'
|
paymentMethod: 'OPTIMISTIC'
|
||||||
}
|
}
|
||||||
|
@ -56,14 +56,7 @@ export default {
|
|||||||
throw new Error('Invoice not found')
|
throw new Error('Invoice not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invoice.actionState !== 'FAILED') {
|
const result = await retryPaidAction(invoice.actionType, { invoiceId }, { models, me, lnd })
|
||||||
if (invoice.actionState === 'PAID') {
|
|
||||||
throw new Error('Invoice is already paid')
|
|
||||||
}
|
|
||||||
throw new Error(`Invoice is not in failed state: ${invoice.actionState}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await retryPaidAction(invoice.actionType, { invoice }, { models, me, lnd })
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
|
@ -128,4 +128,3 @@ riccardobl,pr,#1342,#1141,hard,high,,pending unrelated rearchitecture,1m,rblb@ge
|
|||||||
SatsAllDay,issue,#1368,#1331,medium,,,,25k,weareallsatoshi@getalby.com,2024-09-16
|
SatsAllDay,issue,#1368,#1331,medium,,,,25k,weareallsatoshi@getalby.com,2024-09-16
|
||||||
benalleng,helpfulness,#1368,#1170,medium,,,did a lot of it in #1175,25k,BenAllenG@stacker.news,2024-09-16
|
benalleng,helpfulness,#1368,#1170,medium,,,did a lot of it in #1175,25k,BenAllenG@stacker.news,2024-09-16
|
||||||
humble-GOAT,issue,#1412,#1407,good-first-issue,,,,2k,humble_GOAT@stacker.news,2024-09-18
|
humble-GOAT,issue,#1412,#1407,good-first-issue,,,,2k,humble_GOAT@stacker.news,2024-09-18
|
||||||
felipebueno,issue,#1425,#986,medium,,,,25k,felipebueno@getalby.com,2024-09-26
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
import ArrowLeft from '@/svgs/arrow-left-line.svg'
|
|
||||||
import ArrowRight from '@/svgs/arrow-right-line.svg'
|
|
||||||
import styles from './carousel.module.css'
|
|
||||||
import { useShowModal } from './modal'
|
|
||||||
import { Dropdown } from 'react-bootstrap'
|
|
||||||
|
|
||||||
function useSwiping ({ moveLeft, moveRight }) {
|
|
||||||
const [touchStartX, setTouchStartX] = useState(null)
|
|
||||||
|
|
||||||
const onTouchStart = useCallback((e) => {
|
|
||||||
if (e.touches.length === 1) {
|
|
||||||
setTouchStartX(e.touches[0].clientX)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onTouchEnd = useCallback((e) => {
|
|
||||||
if (touchStartX !== null) {
|
|
||||||
const touchEndX = e.changedTouches[0].clientX
|
|
||||||
const diff = touchEndX - touchStartX
|
|
||||||
if (diff > 50) {
|
|
||||||
moveLeft()
|
|
||||||
} else if (diff < -50) {
|
|
||||||
moveRight()
|
|
||||||
}
|
|
||||||
setTouchStartX(null)
|
|
||||||
}
|
|
||||||
}, [touchStartX, moveLeft, moveRight])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener('touchstart', onTouchStart)
|
|
||||||
document.addEventListener('touchend', onTouchEnd)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('touchstart', onTouchStart)
|
|
||||||
document.removeEventListener('touchend', onTouchEnd)
|
|
||||||
}
|
|
||||||
}, [onTouchStart, onTouchEnd])
|
|
||||||
}
|
|
||||||
|
|
||||||
function useArrowKeys ({ moveLeft, moveRight }) {
|
|
||||||
const onKeyDown = useCallback((e) => {
|
|
||||||
if (e.key === 'ArrowLeft') {
|
|
||||||
moveLeft()
|
|
||||||
} else if (e.key === 'ArrowRight') {
|
|
||||||
moveRight()
|
|
||||||
}
|
|
||||||
}, [moveLeft, moveRight])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener('keydown', onKeyDown)
|
|
||||||
return () => document.removeEventListener('keydown', onKeyDown)
|
|
||||||
}, [onKeyDown])
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Carousel ({ close, mediaArr, src, originalSrc, setOptions }) {
|
|
||||||
const [index, setIndex] = useState(mediaArr.findIndex(([key]) => key === src))
|
|
||||||
const [currentSrc, canGoLeft, canGoRight] = useMemo(() => {
|
|
||||||
return [mediaArr[index][0], index > 0, index < mediaArr.length - 1]
|
|
||||||
}, [mediaArr, index])
|
|
||||||
|
|
||||||
const moveLeft = useCallback(() => {
|
|
||||||
setIndex(i => Math.max(0, i - 1))
|
|
||||||
}, [setIndex])
|
|
||||||
|
|
||||||
const moveRight = useCallback(() => {
|
|
||||||
setIndex(i => Math.min(mediaArr.length - 1, i + 1))
|
|
||||||
}, [setIndex, mediaArr.length])
|
|
||||||
|
|
||||||
useSwiping({ moveLeft, moveRight })
|
|
||||||
useArrowKeys({ moveLeft, moveRight })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.fullScreenContainer} onClick={close}>
|
|
||||||
<img className={styles.fullScreen} src={currentSrc} />
|
|
||||||
<div className={styles.fullScreenNavContainer}>
|
|
||||||
<div
|
|
||||||
className={classNames(styles.fullScreenNav, !canGoLeft && 'invisible', styles.left)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
moveLeft()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowLeft width={34} height={34} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={classNames(styles.fullScreenNav, !canGoRight && 'invisible', styles.right)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
moveRight()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowRight width={34} height={34} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const CarouselContext = createContext()
|
|
||||||
|
|
||||||
function CarouselOverflow ({ originalSrc, rel }) {
|
|
||||||
return <Dropdown.Item href={originalSrc} rel={rel} target='_blank'>view original</Dropdown.Item>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CarouselProvider ({ children }) {
|
|
||||||
const media = useRef(new Map())
|
|
||||||
const showModal = useShowModal()
|
|
||||||
|
|
||||||
const showCarousel = useCallback(({ src }) => {
|
|
||||||
showModal((close, setOptions) => {
|
|
||||||
return <Carousel close={close} mediaArr={Array.from(media.current.entries())} src={src} setOptions={setOptions} />
|
|
||||||
}, {
|
|
||||||
fullScreen: true,
|
|
||||||
overflow: <CarouselOverflow {...media.current.get(src)} />
|
|
||||||
})
|
|
||||||
}, [showModal, media.current])
|
|
||||||
|
|
||||||
const addMedia = useCallback(({ src, originalSrc, rel }) => {
|
|
||||||
media.current.set(src, { src, originalSrc, rel })
|
|
||||||
}, [media.current])
|
|
||||||
|
|
||||||
const removeMedia = useCallback((src) => {
|
|
||||||
media.current.delete(src)
|
|
||||||
}, [media.current])
|
|
||||||
|
|
||||||
const value = useMemo(() => ({ showCarousel, addMedia, removeMedia }), [showCarousel, addMedia, removeMedia])
|
|
||||||
return <CarouselContext.Provider value={value}>{children}</CarouselContext.Provider>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCarousel () {
|
|
||||||
return useContext(CarouselContext)
|
|
||||||
}
|
|
@ -1,63 +0,0 @@
|
|||||||
div.fullScreenNavContainer {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
flex-direction: row;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
img.fullScreen {
|
|
||||||
cursor: zoom-out !important;
|
|
||||||
max-height: 100%;
|
|
||||||
max-width: 100vw;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
align-self: center;
|
|
||||||
justify-self: center;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullScreenContainer {
|
|
||||||
--bs-columns: 1;
|
|
||||||
--bs-rows: 1;
|
|
||||||
display: grid;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.fullScreenNav:hover > svg {
|
|
||||||
background-color: rgba(0, 0, 0, .5);
|
|
||||||
}
|
|
||||||
|
|
||||||
div.fullScreenNav {
|
|
||||||
cursor: pointer;
|
|
||||||
pointer-events: auto;
|
|
||||||
width: 72px;
|
|
||||||
height: 72px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.fullScreenNav.left {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.fullScreenNav.right {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.fullScreenNav > svg {
|
|
||||||
border-radius: 50%;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
background-color: rgba(0, 0, 0, 0.7);
|
|
||||||
fill: white;
|
|
||||||
max-height: 34px;
|
|
||||||
max-width: 34px;
|
|
||||||
padding: 0.35rem;
|
|
||||||
margin: .75rem;
|
|
||||||
}
|
|
@ -94,7 +94,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
|
|||||||
|
|
||||||
export default function Comment ({
|
export default function Comment ({
|
||||||
item, children, replyOpen, includeParent, topLevel,
|
item, children, replyOpen, includeParent, topLevel,
|
||||||
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry
|
rootText, noComments, noReply, truncate, depth, pin
|
||||||
}) {
|
}) {
|
||||||
const [edit, setEdit] = useState()
|
const [edit, setEdit] = useState()
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
@ -169,8 +169,6 @@ export default function Comment ({
|
|||||||
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
|
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
|
||||||
onQuoteReply={quoteReply}
|
onQuoteReply={quoteReply}
|
||||||
nested={!includeParent}
|
nested={!includeParent}
|
||||||
setDisableRetry={setDisableRetry}
|
|
||||||
disableRetry={disableRetry}
|
|
||||||
extraInfo={
|
extraInfo={
|
||||||
<>
|
<>
|
||||||
{includeParent && <Parent item={item} rootText={rootText} />}
|
{includeParent && <Parent item={item} rootText={rootText} />}
|
||||||
|
@ -1,215 +0,0 @@
|
|||||||
import { memo, useEffect, useRef, useState } from 'react'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
import useDarkMode from './dark-mode'
|
|
||||||
import styles from './text.module.css'
|
|
||||||
import { Button } from 'react-bootstrap'
|
|
||||||
import { TwitterTweetEmbed } from 'react-twitter-embed'
|
|
||||||
import YouTube from 'react-youtube'
|
|
||||||
|
|
||||||
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 NostrEmbed = memo(function NostrEmbed ({ src, className, topLevel, darkMode, 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)
|
|
||||||
|
|
||||||
const handleIframeLoad = () => {
|
|
||||||
iframeRef.current.contentWindow.postMessage({ setDarkMode: darkMode }, '*')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (iframeRef.current.complete) {
|
|
||||||
handleIframeLoad()
|
|
||||||
} else {
|
|
||||||
iframeRef.current.addEventListener('load', handleIframeLoad)
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://github.com/vercel/next.js/issues/39451
|
|
||||||
iframeRef.current.src = `https://njump.me/${id}?embed=yes`
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window?.removeEventListener('message', setHeightFromIframe)
|
|
||||||
iframeRef.current?.removeEventListener('load', handleIframeLoad)
|
|
||||||
}
|
|
||||||
}, [iframeRef.current, darkMode])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames(styles.nostrContainer, !show && styles.twitterContained, className)}>
|
|
||||||
<iframe
|
|
||||||
ref={iframeRef}
|
|
||||||
width={topLevel ? '550px' : '350px'}
|
|
||||||
style={{ maxWidth: '100%' }}
|
|
||||||
height={iframeRef.current?.height || (topLevel ? '200px' : '150px')}
|
|
||||||
frameBorder='0'
|
|
||||||
sandbox='allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox'
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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} darkMode={darkMode} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 allow-popups allow-popups-to-escape-sandbox allow-forms allow-same-origin'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === 'spotify') {
|
|
||||||
return (
|
|
||||||
<SpotifyEmbed src={src} className={className} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
export default Embed
|
|
@ -50,6 +50,7 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const repetition = data?.itemRepetition
|
const repetition = data?.itemRepetition
|
||||||
if (!repetition) return setLine({})
|
if (!repetition) return setLine({})
|
||||||
|
console.log('repetition', repetition)
|
||||||
setLine({
|
setLine({
|
||||||
itemRepetition: {
|
itemRepetition: {
|
||||||
term: <>x 10<sup>{repetition}</sup></>,
|
term: <>x 10<sup>{repetition}</sup></>,
|
||||||
|
@ -53,7 +53,7 @@ export function SubmitButton ({
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant={variant || 'main'}
|
variant={variant || 'main'}
|
||||||
className={classNames(formik.isSubmitting && 'pulse', className)}
|
className={classNames(formik.isSubmitting && styles.pending, className)}
|
||||||
type='submit'
|
type='submit'
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={value
|
onClick={value
|
||||||
@ -393,7 +393,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
{tab !== 'write' &&
|
{tab !== 'write' &&
|
||||||
<div className='form-group'>
|
<div className='form-group'>
|
||||||
<div className={`${styles.text} form-control`}>
|
<div className={`${styles.text} form-control`}>
|
||||||
<Text topLevel={topLevel} tab={tab}>{meta.value}</Text>
|
<Text topLevel={topLevel} noFragments tab={tab}>{meta.value}</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
|
@ -13,12 +13,8 @@ import ItemJob from './item-job'
|
|||||||
import Item from './item'
|
import Item from './item'
|
||||||
import { CommentFlat } from './comment'
|
import { CommentFlat } from './comment'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import Moon from '@/svgs/moon-fill.svg'
|
|
||||||
|
|
||||||
export default function Invoice ({
|
export default function Invoice ({ id, query = INVOICE, modal, onPayment, onCanceled, info, successVerb, useWallet = true, walletError, poll, waitFor, ...props }) {
|
||||||
id, query = INVOICE, modal, onPayment, onCanceled, info, successVerb = 'deposited',
|
|
||||||
heldVerb = 'settling', useWallet = true, walletError, poll, waitFor, ...props
|
|
||||||
}) {
|
|
||||||
const [expired, setExpired] = useState(false)
|
const [expired, setExpired] = useState(false)
|
||||||
const { data, error } = useQuery(query, SSR
|
const { data, error } = useQuery(query, SSR
|
||||||
? {}
|
? {}
|
||||||
@ -59,17 +55,9 @@ export default function Invoice ({
|
|||||||
variant = 'failed'
|
variant = 'failed'
|
||||||
status = 'cancelled'
|
status = 'cancelled'
|
||||||
useWallet = false
|
useWallet = false
|
||||||
} else if (invoice.isHeld && invoice.satsReceived && !expired) {
|
} else if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived && !expired)) {
|
||||||
variant = 'pending'
|
|
||||||
status = (
|
|
||||||
<div className='d-flex justify-content-center'>
|
|
||||||
<Moon className='spin fill-grey me-2' /> {heldVerb}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
useWallet = false
|
|
||||||
} else if (invoice.confirmedAt) {
|
|
||||||
variant = 'confirmed'
|
variant = 'confirmed'
|
||||||
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb}`
|
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}`
|
||||||
useWallet = false
|
useWallet = false
|
||||||
} else if (expired) {
|
} else if (expired) {
|
||||||
variant = 'failed'
|
variant = 'failed'
|
||||||
|
@ -90,7 +90,6 @@ function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'B
|
|||||||
export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
|
export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
const wallet = useWallet()
|
|
||||||
const [oValue, setOValue] = useState()
|
const [oValue, setOValue] = useState()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -111,18 +110,6 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPaid = () => {
|
|
||||||
strike()
|
|
||||||
onClose?.()
|
|
||||||
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeImmediately = !!wallet || me?.privates?.sats > Number(amount)
|
|
||||||
if (closeImmediately) {
|
|
||||||
onPaid()
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await actor({
|
const { error } = await actor({
|
||||||
variables: {
|
variables: {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@ -140,11 +127,15 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
// don't close modal immediately because we want the QR modal to stack
|
// don't close modal immediately because we want the QR modal to stack
|
||||||
onPaid: closeImmediately ? undefined : onPaid
|
onCompleted: () => {
|
||||||
|
strike()
|
||||||
|
onClose?.()
|
||||||
|
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
addCustomTip(Number(amount))
|
addCustomTip(Number(amount))
|
||||||
}, [me, actor, !!wallet, act, item.id, onClose, abortSignal, strike])
|
}, [me, actor, act, item.id, onClose, abortSignal, strike])
|
||||||
|
|
||||||
return act === 'BOOST'
|
return act === 'BOOST'
|
||||||
? <BoostForm step={step} onSubmit={onSubmit} item={item} oValue={oValue} inputRef={inputRef} act={act}>{children}</BoostForm>
|
? <BoostForm step={step} onSubmit={onSubmit} item={item} oValue={oValue} inputRef={inputRef} act={act}>{children}</BoostForm>
|
||||||
@ -235,7 +226,6 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
|
|||||||
const getPaidActionResult = data => Object.values(data)[0]
|
const getPaidActionResult = data => Object.values(data)[0]
|
||||||
|
|
||||||
const [act] = usePaidMutation(query, {
|
const [act] = usePaidMutation(query, {
|
||||||
waitFor: inv => inv?.satsReceived > 0,
|
|
||||||
...options,
|
...options,
|
||||||
update: (cache, { data }) => {
|
update: (cache, { data }) => {
|
||||||
const response = getPaidActionResult(data)
|
const response = getPaidActionResult(data)
|
||||||
|
@ -19,13 +19,11 @@ 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 { decodeProxyUrl, 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'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { CarouselProvider } from './carousel'
|
|
||||||
import Embed from './embed'
|
|
||||||
|
|
||||||
function BioItem ({ item, handleClick }) {
|
function BioItem ({ item, handleClick }) {
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
@ -51,18 +49,10 @@ function BioItem ({ item, handleClick }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ItemEmbed ({ url, imgproxyUrls }) {
|
function ItemEmbed ({ url, imgproxyUrls }) {
|
||||||
if (imgproxyUrls) {
|
const src = IMGPROXY_URL_REGEXP.test(url) ? decodeProxyUrl(url) : url
|
||||||
const src = IMGPROXY_URL_REGEXP.test(url) ? decodeProxyUrl(url) : url
|
const srcSet = imgproxyUrls?.[url]
|
||||||
const srcSet = imgproxyUrls?.[url]
|
|
||||||
return <MediaOrLink src={src} srcSet={srcSet} topLevel linkFallback={false} />
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = parseEmbedUrl(url)
|
return <MediaOrLink src={src} srcSet={srcSet} topLevel linkFallback={false} />
|
||||||
if (provider) {
|
|
||||||
return <Embed url={url} {...provider} topLevel />
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FwdUsers ({ forwards }) {
|
function FwdUsers ({ forwards }) {
|
||||||
@ -166,22 +156,20 @@ export default function ItemFull ({ item, bio, rank, ...props }) {
|
|||||||
</div>)
|
</div>)
|
||||||
: <div />}
|
: <div />}
|
||||||
<RootProvider root={item.root || item}>
|
<RootProvider root={item.root || item}>
|
||||||
<CarouselProvider key={item.id}>
|
{item.parentId
|
||||||
{item.parentId
|
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
|
||||||
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
|
: (
|
||||||
: (
|
<div>{bio
|
||||||
<div>{bio
|
? <BioItem item={item} {...props} />
|
||||||
? <BioItem item={item} {...props} />
|
: <TopLevelItem item={item} {...props} />}
|
||||||
: <TopLevelItem item={item} {...props} />}
|
</div>)}
|
||||||
</div>)}
|
{item.comments &&
|
||||||
{item.comments &&
|
<div className={styles.comments}>
|
||||||
<div className={styles.comments}>
|
<Comments
|
||||||
<Comments
|
parentId={item.id} parentCreatedAt={item.createdAt}
|
||||||
parentId={item.id} parentCreatedAt={item.createdAt}
|
pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments}
|
||||||
pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments}
|
/>
|
||||||
/>
|
</div>}
|
||||||
</div>}
|
|
||||||
</CarouselProvider>
|
|
||||||
</RootProvider>
|
</RootProvider>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -26,20 +26,20 @@ import { useQrPayment } from './payment'
|
|||||||
import { useRetryCreateItem } from './use-item-submit'
|
import { useRetryCreateItem } from './use-item-submit'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import classNames from 'classnames'
|
|
||||||
|
|
||||||
export default function ItemInfo ({
|
export default function ItemInfo ({
|
||||||
item, full, commentsText = 'comments',
|
item, full, commentsText = 'comments',
|
||||||
commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText,
|
commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText,
|
||||||
onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true,
|
onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true
|
||||||
setDisableRetry, disableRetry
|
|
||||||
}) {
|
}) {
|
||||||
const editThreshold = new Date(item.invoice?.confirmedAt ?? item.createdAt).getTime() + 10 * 60000
|
const editThreshold = new Date(item.invoice?.confirmedAt ?? item.createdAt).getTime() + 10 * 60000
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
|
const toaster = useToast()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [canEdit, setCanEdit] = useState(item.mine && (Date.now() < editThreshold))
|
const [canEdit, setCanEdit] = useState(item.mine && (Date.now() < editThreshold))
|
||||||
const [hasNewComments, setHasNewComments] = useState(false)
|
const [hasNewComments, setHasNewComments] = useState(false)
|
||||||
const root = useRoot()
|
const root = useRoot()
|
||||||
|
const retryCreateItem = useRetryCreateItem({ id: item.id })
|
||||||
const sub = item?.sub || root?.sub
|
const sub = item?.sub || root?.sub
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -64,6 +64,72 @@ export default function ItemInfo ({
|
|||||||
const canPin = (isPost && mySub) || (myPost && rootReply)
|
const canPin = (isPost && mySub) || (myPost && rootReply)
|
||||||
const meSats = (me ? item.meSats : item.meAnonSats) || 0
|
const meSats = (me ? item.meSats : item.meAnonSats) || 0
|
||||||
|
|
||||||
|
const EditInfo = () => {
|
||||||
|
if (canEdit) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span> \ </span>
|
||||||
|
<span
|
||||||
|
className='text-reset pointer fw-bold'
|
||||||
|
onClick={() => onEdit ? onEdit() : router.push(`/items/${item.id}/edit`)}
|
||||||
|
>
|
||||||
|
<span>{editText || 'edit'} </span>
|
||||||
|
{(!item.invoice?.actionState || item.invoice?.actionState === 'PAID') &&
|
||||||
|
<Countdown
|
||||||
|
date={editThreshold}
|
||||||
|
onComplete={() => { setCanEdit(false) }}
|
||||||
|
/>}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaymentInfo = () => {
|
||||||
|
const waitForQrPayment = useQrPayment()
|
||||||
|
if (item.deletedAt) return null
|
||||||
|
|
||||||
|
let Component
|
||||||
|
let onClick
|
||||||
|
if (me && item.invoice?.actionState && item.invoice?.actionState !== 'PAID') {
|
||||||
|
if (item.invoice?.actionState === 'FAILED') {
|
||||||
|
Component = () => <span className='text-warning'>retry payment</span>
|
||||||
|
onClick = async () => {
|
||||||
|
try {
|
||||||
|
const { error } = await retryCreateItem({ variables: { invoiceId: parseInt(item.invoice?.id) } })
|
||||||
|
if (error) throw error
|
||||||
|
} catch (error) {
|
||||||
|
toaster.danger(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Component = () => (
|
||||||
|
<span
|
||||||
|
className='text-info'
|
||||||
|
>pending
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
onClick = () => waitForQrPayment({ id: item.invoice?.id }, null, { cancelOnClose: false }).catch(console.error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span> \ </span>
|
||||||
|
<span
|
||||||
|
className='text-reset pointer fw-bold'
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Component />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className || `${styles.other}`}>
|
<div className={className || `${styles.other}`}>
|
||||||
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) &&
|
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) &&
|
||||||
@ -151,11 +217,8 @@ export default function ItemInfo ({
|
|||||||
{
|
{
|
||||||
showActionDropdown &&
|
showActionDropdown &&
|
||||||
<>
|
<>
|
||||||
<EditInfo
|
<EditInfo />
|
||||||
item={item} canEdit={canEdit}
|
<PaymentInfo />
|
||||||
setCanEdit={setCanEdit} onEdit={onEdit} editText={editText} editThreshold={editThreshold}
|
|
||||||
/>
|
|
||||||
<PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} />
|
|
||||||
<ActionDropdown>
|
<ActionDropdown>
|
||||||
<CopyLinkDropdownItem item={item} />
|
<CopyLinkDropdownItem item={item} />
|
||||||
<InfoDropdownItem item={item} />
|
<InfoDropdownItem item={item} />
|
||||||
@ -254,86 +317,3 @@ function InfoDropdownItem ({ item }) {
|
|||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaymentInfo ({ item, disableRetry, setDisableRetry }) {
|
|
||||||
const { me } = useMe()
|
|
||||||
const toaster = useToast()
|
|
||||||
const retryCreateItem = useRetryCreateItem({ id: item.id })
|
|
||||||
const waitForQrPayment = useQrPayment()
|
|
||||||
const [disableInfoRetry, setDisableInfoRetry] = useState(disableRetry)
|
|
||||||
if (item.deletedAt) return null
|
|
||||||
|
|
||||||
const disableDualRetry = disableRetry || disableInfoRetry
|
|
||||||
function setDisableDualRetry (value) {
|
|
||||||
setDisableInfoRetry(value)
|
|
||||||
setDisableRetry?.(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
let Component
|
|
||||||
let onClick
|
|
||||||
if (me && item.invoice?.actionState && item.invoice?.actionState !== 'PAID') {
|
|
||||||
if (item.invoice?.actionState === 'FAILED') {
|
|
||||||
Component = () => <span className={classNames('text-warning', disableDualRetry && 'pulse')}>retry payment</span>
|
|
||||||
onClick = async () => {
|
|
||||||
if (disableDualRetry) return
|
|
||||||
setDisableDualRetry(true)
|
|
||||||
try {
|
|
||||||
const { error } = await retryCreateItem({ variables: { invoiceId: parseInt(item.invoice?.id) } })
|
|
||||||
if (error) throw error
|
|
||||||
} catch (error) {
|
|
||||||
toaster.danger(error.message)
|
|
||||||
} finally {
|
|
||||||
setDisableDualRetry(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Component = () => (
|
|
||||||
<span
|
|
||||||
className='text-info'
|
|
||||||
>pending
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
onClick = () => waitForQrPayment({ id: item.invoice?.id }, null, { cancelOnClose: false }).catch(console.error)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span> \ </span>
|
|
||||||
<span
|
|
||||||
className='text-reset pointer fw-bold'
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<Component />
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditInfo ({ item, canEdit, setCanEdit, onEdit, editText, editThreshold }) {
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
if (canEdit) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span> \ </span>
|
|
||||||
<span
|
|
||||||
className='text-reset pointer fw-bold'
|
|
||||||
onClick={() => onEdit ? onEdit() : router.push(`/items/${item.id}/edit`)}
|
|
||||||
>
|
|
||||||
<span>{editText || 'edit'} </span>
|
|
||||||
{(!item.invoice?.actionState || item.invoice?.actionState === 'PAID')
|
|
||||||
? <Countdown
|
|
||||||
date={editThreshold}
|
|
||||||
onComplete={() => { setCanEdit(false) }}
|
|
||||||
/>
|
|
||||||
: <span>10:00</span>}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
@ -87,7 +87,7 @@ function ItemLink ({ url, rel }) {
|
|||||||
|
|
||||||
export default function Item ({
|
export default function Item ({
|
||||||
item, rank, belowTitle, right, full, children, itemClassName,
|
item, rank, belowTitle, right, full, children, itemClassName,
|
||||||
onQuoteReply, pinnable, setDisableRetry, disableRetry
|
onQuoteReply, pinnable
|
||||||
}) {
|
}) {
|
||||||
const titleRef = useRef()
|
const titleRef = useRef()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -139,8 +139,6 @@ export default function Item ({
|
|||||||
onQuoteReply={onQuoteReply}
|
onQuoteReply={onQuoteReply}
|
||||||
pinnable={pinnable}
|
pinnable={pinnable}
|
||||||
extraBadges={Number(item?.user?.id) === USER_ID.ad && <Badge className={styles.newComment} bg={null}>AD</Badge>}
|
extraBadges={Number(item?.user?.id) === USER_ID.ad && <Badge className={styles.newComment} bg={null}>AD</Badge>}
|
||||||
setDisableRetry={setDisableRetry}
|
|
||||||
disableRetry={disableRetry}
|
|
||||||
/>
|
/>
|
||||||
{belowTitle}
|
{belowTitle}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,10 +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 { useMe } from './me'
|
import { useMe } from './me'
|
||||||
|
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 { useCarousel } from './carousel'
|
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])
|
||||||
@ -19,15 +23,9 @@ function LinkRaw ({ href, children, src, rel }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Media = memo(function Media ({
|
const Media = memo(function Media ({ src, bestResSrc, srcSet, sizes, width, height, onClick, onError, style, className, video }) {
|
||||||
src, bestResSrc, srcSet, sizes, width,
|
|
||||||
height, onClick, onError, style, className, video
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={classNames(className, styles.mediaContainer)} style={style}>
|
||||||
className={classNames(className, styles.mediaContainer)}
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
{video
|
{video
|
||||||
? <video
|
? <video
|
||||||
src={src}
|
src={src}
|
||||||
@ -54,21 +52,32 @@ const Media = memo(function Media ({
|
|||||||
export default function MediaOrLink ({ linkFallback = true, ...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 { showCarousel, addMedia, removeMedia } = useCarousel()
|
const showModal = useShowModal()
|
||||||
|
|
||||||
useEffect(() => {
|
const handleClick = useCallback(() => showModal(close => {
|
||||||
if (!media.image) return
|
return (
|
||||||
addMedia({ src: media.bestResSrc, originalSrc: media.originalSrc, rel: props.rel })
|
<div
|
||||||
}, [media.image])
|
className={styles.fullScreenContainer}
|
||||||
|
onClick={close}
|
||||||
const handleClick = useCallback(() => showCarousel({ src: media.bestResSrc }),
|
>
|
||||||
[showCarousel, media.bestResSrc])
|
<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) => {
|
const handleError = useCallback((err) => {
|
||||||
console.error('Error loading media', err)
|
console.error('Error loading media', err)
|
||||||
removeMedia(media.bestResSrc)
|
|
||||||
setError(true)
|
setError(true)
|
||||||
}, [setError, removeMedia, media.bestResSrc])
|
}, [setError])
|
||||||
|
|
||||||
if (!media.src) return null
|
if (!media.src) return null
|
||||||
|
|
||||||
@ -80,6 +89,14 @@ export default function MediaOrLink ({ linkFallback = true, ...props }) {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (media.embed) {
|
||||||
|
return (
|
||||||
|
<Embed
|
||||||
|
{...media.embed} topLevel={props.topLevel} src={media.src} onError={handleError}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (linkFallback) {
|
if (linkFallback) {
|
||||||
@ -97,10 +114,11 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
|
|||||||
const [isImage, setIsImage] = useState(video === false && trusted)
|
const [isImage, setIsImage] = useState(video === false && 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 user 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)
|
||||||
@ -115,7 +133,7 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
|
|||||||
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 +182,203 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
|
|||||||
style,
|
style,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
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 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 (
|
||||||
|
<div className={classNames(styles.nostrContainer, !show && styles.twitterContained, className)}>
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
width={topLevel ? '550px' : '350px'}
|
||||||
|
style={{ maxWidth: '100%' }}
|
||||||
|
height={iframeRef.current?.height || (topLevel ? '200px' : '150px')}
|
||||||
|
frameBorder='0'
|
||||||
|
sandbox='allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox'
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 allow-popups allow-popups-to-escape-sandbox allow-forms allow-same-origin'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'spotify') {
|
||||||
|
return (
|
||||||
|
<SpotifyEmbed src={src} className={className} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
@ -36,14 +36,6 @@ export default function useModal () {
|
|||||||
forceUpdate()
|
forceUpdate()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const setOptions = useCallback(options => {
|
|
||||||
const current = getCurrentContent()
|
|
||||||
if (current) {
|
|
||||||
current.options = { ...current.options, ...options }
|
|
||||||
forceUpdate()
|
|
||||||
}
|
|
||||||
}, [getCurrentContent, forceUpdate])
|
|
||||||
|
|
||||||
// this is called on every navigation due to below useEffect
|
// this is called on every navigation due to below useEffect
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
while (modalStack.current.length) {
|
while (modalStack.current.length) {
|
||||||
@ -102,7 +94,7 @@ export default function useModal () {
|
|||||||
|
|
||||||
const showModal = useCallback(
|
const showModal = useCallback(
|
||||||
(getContent, options) => {
|
(getContent, options) => {
|
||||||
const ref = { node: getContent(onClose, setOptions), options }
|
const ref = { node: getContent(onClose), options }
|
||||||
if (options?.replaceModal) {
|
if (options?.replaceModal) {
|
||||||
modalStack.current = [ref]
|
modalStack.current = [ref]
|
||||||
} else {
|
} else {
|
||||||
|
@ -38,7 +38,6 @@ import { paidActionCacheMods } from './use-paid-mutation'
|
|||||||
import { useRetryCreateItem } from './use-item-submit'
|
import { useRetryCreateItem } from './use-item-submit'
|
||||||
import { payBountyCacheMods } from './pay-bounty'
|
import { payBountyCacheMods } from './pay-bounty'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import classNames from 'classnames'
|
|
||||||
|
|
||||||
function Notification ({ n, fresh }) {
|
function Notification ({ n, fresh }) {
|
||||||
const type = n.__typename
|
const type = n.__typename
|
||||||
@ -103,14 +102,14 @@ function NoteHeader ({ color, children, big }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoteItem ({ item, ...props }) {
|
function NoteItem ({ item }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{item.title
|
{item.title
|
||||||
? <Item item={item} itemClassName='pt-0' {...props} />
|
? <Item item={item} itemClassName='pt-0' />
|
||||||
: (
|
: (
|
||||||
<RootProvider root={item.root}>
|
<RootProvider root={item.root}>
|
||||||
<Comment item={item} noReply includeParent clickToContext {...props} />
|
<Comment item={item} noReply includeParent clickToContext />
|
||||||
</RootProvider>)}
|
</RootProvider>)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -344,10 +343,7 @@ function InvoicePaid ({ n }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useActRetry ({ invoice }) {
|
function useActRetry ({ invoice }) {
|
||||||
const bountyCacheMods =
|
const bountyCacheMods = invoice.item?.bounty ? payBountyCacheMods() : {}
|
||||||
invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine
|
|
||||||
? payBountyCacheMods
|
|
||||||
: {}
|
|
||||||
return useAct({
|
return useAct({
|
||||||
query: RETRY_PAID_ACTION,
|
query: RETRY_PAID_ACTION,
|
||||||
onPayError: (e, cache, { data }) => {
|
onPayError: (e, cache, { data }) => {
|
||||||
@ -387,7 +383,6 @@ function Invoicification ({ n: { invoice, sortTime } }) {
|
|||||||
const actRetry = useActRetry({ invoice })
|
const actRetry = useActRetry({ invoice })
|
||||||
const retryCreateItem = useRetryCreateItem({ id: invoice.item?.id })
|
const retryCreateItem = useRetryCreateItem({ id: invoice.item?.id })
|
||||||
const retryPollVote = usePollVote({ query: RETRY_PAID_ACTION, itemId: invoice.item?.id })
|
const retryPollVote = usePollVote({ query: RETRY_PAID_ACTION, itemId: invoice.item?.id })
|
||||||
const [disableRetry, setDisableRetry] = useState(false)
|
|
||||||
// XXX if we navigate to an invoice after it is retried in notifications
|
// XXX if we navigate to an invoice after it is retried in notifications
|
||||||
// the cache will clear invoice.item and will error on window.back
|
// the cache will clear invoice.item and will error on window.back
|
||||||
// alternatively, we could/should
|
// alternatively, we could/should
|
||||||
@ -412,7 +407,7 @@ function Invoicification ({ n: { invoice, sortTime } }) {
|
|||||||
invoiceActionState = invoice.item.poll?.meInvoiceActionState
|
invoiceActionState = invoice.item.poll?.meInvoiceActionState
|
||||||
} else {
|
} else {
|
||||||
if (invoice.actionType === 'ZAP') {
|
if (invoice.actionType === 'ZAP') {
|
||||||
if (invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine) {
|
if (invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root.mine) {
|
||||||
actionString = 'bounty payment'
|
actionString = 'bounty payment'
|
||||||
} else {
|
} else {
|
||||||
actionString = 'zap'
|
actionString = 'zap'
|
||||||
@ -448,19 +443,14 @@ function Invoicification ({ n: { invoice, sortTime } }) {
|
|||||||
<span className='ms-1 text-muted fw-light'> {numWithUnits(invoice.satsRequested)}</span>
|
<span className='ms-1 text-muted fw-light'> {numWithUnits(invoice.satsRequested)}</span>
|
||||||
<span className={invoiceActionState === 'FAILED' ? 'visible' : 'invisible'}>
|
<span className={invoiceActionState === 'FAILED' ? 'visible' : 'invisible'}>
|
||||||
<Button
|
<Button
|
||||||
size='sm' variant={classNames('outline-warning ms-2 border-1 rounded py-0', disableRetry && 'pulse')}
|
size='sm' variant='outline-warning ms-2 border-1 rounded py-0'
|
||||||
style={{ '--bs-btn-hover-color': '#fff', '--bs-btn-active-color': '#fff' }}
|
style={{ '--bs-btn-hover-color': '#fff', '--bs-btn-active-color': '#fff' }}
|
||||||
disabled={disableRetry}
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (disableRetry) return
|
|
||||||
setDisableRetry(true)
|
|
||||||
try {
|
try {
|
||||||
const { error } = await retry({ variables: { invoiceId: parseInt(invoiceId) } })
|
const { error } = await retry({ variables: { invoiceId: parseInt(invoiceId) } })
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toaster.danger(error?.message || error?.toString?.())
|
toaster.danger(error?.message || error?.toString?.())
|
||||||
} finally {
|
|
||||||
setDisableRetry(false)
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -469,7 +459,7 @@ function Invoicification ({ n: { invoice, sortTime } }) {
|
|||||||
<span className='text-muted ms-2 fw-normal' suppressHydrationWarning>{timeSince(new Date(sortTime))}</span>
|
<span className='text-muted ms-2 fw-normal' suppressHydrationWarning>{timeSince(new Date(sortTime))}</span>
|
||||||
</span>
|
</span>
|
||||||
</NoteHeader>
|
</NoteHeader>
|
||||||
<NoteItem item={invoice.item} setDisableRetry={setDisableRetry} disableRetry={disableRetry} />
|
<NoteItem item={invoice.item} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,6 @@ import { useRoot } from './root'
|
|||||||
import { commentSubTreeRootId } from '@/lib/item'
|
import { commentSubTreeRootId } from '@/lib/item'
|
||||||
import { CREATE_COMMENT } from '@/fragments/paidAction'
|
import { CREATE_COMMENT } from '@/fragments/paidAction'
|
||||||
import useItemSubmit from './use-item-submit'
|
import useItemSubmit from './use-item-submit'
|
||||||
import gql from 'graphql-tag'
|
|
||||||
|
|
||||||
export function ReplyOnAnotherPage ({ item }) {
|
export function ReplyOnAnotherPage ({ item }) {
|
||||||
const rootId = commentSubTreeRootId(item)
|
const rootId = commentSubTreeRootId(item)
|
||||||
@ -81,17 +80,6 @@ export default forwardRef(function Reply ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// no lag for itemRepetition
|
|
||||||
if (!item.mine && me) {
|
|
||||||
cache.updateQuery({
|
|
||||||
query: gql`{ itemRepetition(parentId: "${parentId}") }`
|
|
||||||
}, data => {
|
|
||||||
return {
|
|
||||||
itemRepetition: (data?.itemRepetition || 0) + 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const ancestors = item.path.split('.')
|
const ancestors = item.path.split('.')
|
||||||
|
|
||||||
// update all ancestors
|
// update all ancestors
|
||||||
|
@ -3,10 +3,18 @@ import ReactMarkdown from 'react-markdown'
|
|||||||
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'
|
||||||
|
import mention from '@/lib/remark-mention'
|
||||||
|
import sub from '@/lib/remark-sub'
|
||||||
import React, { useState, memo, useRef, useCallback, useMemo, useEffect } from 'react'
|
import React, { useState, memo, useRef, useCallback, useMemo, useEffect } from 'react'
|
||||||
|
import { slug } from 'github-slugger'
|
||||||
|
import LinkIcon from '@/svgs/link.svg'
|
||||||
|
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 MediaOrLink from './media-or-link'
|
||||||
import { IMGPROXY_URL_REGEXP, 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 { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@ -15,24 +23,10 @@ import isEqual from 'lodash/isEqual'
|
|||||||
import UserPopover from './user-popover'
|
import UserPopover from './user-popover'
|
||||||
import ItemPopover from './item-popover'
|
import ItemPopover from './item-popover'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { CarouselProvider, useCarousel } from './carousel'
|
|
||||||
import rehypeSN from '@/lib/rehype-sn'
|
|
||||||
import Embed from './embed'
|
|
||||||
|
|
||||||
const rehypeSNStyled = () => rehypeSN({
|
// Explicitely defined start/end tags & which CSS class from text.module.css to apply
|
||||||
stylers: [{
|
export const rehypeSuperscript = () => rehypeStyler('<sup>', '</sup>', styles.superscript)
|
||||||
startTag: '<sup>',
|
export const rehypeSubscript = () => rehypeStyler('<sub>', '</sub>', styles.subscript)
|
||||||
endTag: '</sup>',
|
|
||||||
className: styles.superscript
|
|
||||||
}, {
|
|
||||||
startTag: '<sub>',
|
|
||||||
endTag: '</sub>',
|
|
||||||
className: styles.subscript
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
const remarkPlugins = [gfm]
|
|
||||||
const rehypePlugins = [rehypeSNStyled]
|
|
||||||
|
|
||||||
export function SearchText ({ text }) {
|
export function SearchText ({ text }) {
|
||||||
return (
|
return (
|
||||||
@ -47,17 +41,16 @@ export function SearchText ({ text }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// this is one of the slowest components to render
|
// this is one of the slowest components to render
|
||||||
export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, children, tab, itemId, outlawed, topLevel }) {
|
export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, outlawed, topLevel, noFragments }) {
|
||||||
const [overflowing, setOverflowing] = useState(false)
|
const [overflowing, setOverflowing] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
const containerRef = useRef(null)
|
const containerRef = useRef(null)
|
||||||
|
|
||||||
// if we are navigating to a hash, show the full text
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShow(router.asPath.includes('#') && !router.asPath.includes('#itemfn-'))
|
setShow(router.asPath.includes('#'))
|
||||||
const handleRouteChange = (url, { shallow }) => {
|
const handleRouteChange = (url, { shallow }) => {
|
||||||
setShow(url.includes('#') && !url.includes('#itemfn-'))
|
setShow(url.includes('#'))
|
||||||
}
|
}
|
||||||
|
|
||||||
router.events.on('hashChangeStart', handleRouteChange)
|
router.events.on('hashChangeStart', handleRouteChange)
|
||||||
@ -65,9 +58,8 @@ export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, child
|
|||||||
return () => {
|
return () => {
|
||||||
router.events.off('hashChangeStart', handleRouteChange)
|
router.events.off('hashChangeStart', handleRouteChange)
|
||||||
}
|
}
|
||||||
}, [router.asPath, router.events])
|
}, [router])
|
||||||
|
|
||||||
// clip item and give it a`show full text` button if we are overflowing
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
if (!container || overflowing) return
|
if (!container || overflowing) return
|
||||||
@ -90,157 +82,199 @@ export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, child
|
|||||||
}
|
}
|
||||||
}, [containerRef.current, setOverflowing])
|
}, [containerRef.current, setOverflowing])
|
||||||
|
|
||||||
const TextMediaOrLink = useCallback(props => {
|
const Heading = useCallback(({ children, node, ...props }) => {
|
||||||
return <MediaLink {...props} outlawed={outlawed} imgproxyUrls={imgproxyUrls} topLevel={topLevel} rel={rel} />
|
const [copied, setCopied] = useState(false)
|
||||||
},
|
const nodeText = toString(node)
|
||||||
[outlawed, imgproxyUrls, topLevel, rel])
|
const id = useMemo(() => noFragments ? undefined : slug(nodeText.replace(/[^\w\-\s]+/gi, '')), [nodeText, noFragments])
|
||||||
|
const h = useMemo(() => {
|
||||||
|
if (topLevel) {
|
||||||
|
return node?.TagName
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = parseInt(node?.tagName?.replace('h', '') || 0)
|
||||||
|
if (h < 4) return `h${h + 3}`
|
||||||
|
|
||||||
|
return 'h6'
|
||||||
|
}, [node, topLevel])
|
||||||
|
const Icon = copied ? Thumb : LinkIcon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={styles.heading}>
|
||||||
|
{React.createElement(h || node?.tagName, { id, ...props }, children)}
|
||||||
|
{!noFragments && topLevel &&
|
||||||
|
<a className={`${styles.headingLink} ${copied ? styles.copied : ''}`} href={`#${id}`}>
|
||||||
|
<Icon
|
||||||
|
onClick={() => {
|
||||||
|
const location = new URL(window.location)
|
||||||
|
location.hash = `${id}`
|
||||||
|
copy(location.href)
|
||||||
|
setTimeout(() => setCopied(false), 1500)
|
||||||
|
setCopied(true)
|
||||||
|
}}
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
className='fill-grey'
|
||||||
|
/>
|
||||||
|
</a>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}, [topLevel, noFragments])
|
||||||
|
|
||||||
|
const Table = useCallback(({ node, ...props }) =>
|
||||||
|
<span className='table-responsive'>
|
||||||
|
<table className='table table-bordered table-sm' {...props} />
|
||||||
|
</span>, [])
|
||||||
|
|
||||||
|
const Code = useCallback(({ node, inline, className, children, style, ...props }) => {
|
||||||
|
return inline
|
||||||
|
? (
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<SyntaxHighlighter style={atomDark} language='text' PreTag='div' {...props}>
|
||||||
|
{children}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const P = useCallback(({ children, node, ...props }) => <div className={styles.p} {...props}>{children}</div>, [])
|
||||||
|
|
||||||
|
const TextMediaOrLink = useCallback(({ node, src, ...props }) => {
|
||||||
|
const url = IMGPROXY_URL_REGEXP.test(src) ? decodeProxyUrl(src) : src
|
||||||
|
// if outlawed, render the media link as text
|
||||||
|
if (outlawed) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
const srcSet = imgproxyUrls?.[url]
|
||||||
|
return <MediaOrLink srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel={topLevel} />
|
||||||
|
}, [imgproxyUrls, topLevel, tab])
|
||||||
|
|
||||||
const components = useMemo(() => ({
|
const components = useMemo(() => ({
|
||||||
h1: ({ node, id, ...props }) => <h1 id={topLevel ? id : undefined} {...props} />,
|
h1: Heading,
|
||||||
h2: ({ node, id, ...props }) => <h2 id={topLevel ? id : undefined} {...props} />,
|
h2: Heading,
|
||||||
h3: ({ node, id, ...props }) => <h3 id={topLevel ? id : undefined} {...props} />,
|
h3: Heading,
|
||||||
h4: ({ node, id, ...props }) => <h4 id={topLevel ? id : undefined} {...props} />,
|
h4: Heading,
|
||||||
h5: ({ node, id, ...props }) => <h5 id={topLevel ? id : undefined} {...props} />,
|
h5: Heading,
|
||||||
h6: ({ node, id, ...props }) => <h6 id={topLevel ? id : undefined} {...props} />,
|
h6: Heading,
|
||||||
table: Table,
|
table: Table,
|
||||||
p: P,
|
p: P,
|
||||||
|
li: props => {
|
||||||
|
return <li {...props} id={props.id && itemId ? `${props.id}-${itemId}` : props.id} />
|
||||||
|
},
|
||||||
code: Code,
|
code: Code,
|
||||||
mention: Mention,
|
|
||||||
sub: Sub,
|
|
||||||
item: Item,
|
|
||||||
footnote: Footnote,
|
|
||||||
headlink: ({ node, href, ...props }) => <Link href={href} {...props} />,
|
|
||||||
autolink: ({ href, ...props }) => <TextMediaOrLink src={href} {...props} />,
|
|
||||||
a: ({ node, href, children, ...props }) => {
|
a: ({ node, href, children, ...props }) => {
|
||||||
|
children = children ? Array.isArray(children) ? children : [children] : []
|
||||||
|
// don't allow zoomable images to be wrapped in links
|
||||||
|
if (children.some(e => e?.props?.node?.tagName === 'img')) {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
// if outlawed, render the link as text
|
// if outlawed, render the link as text
|
||||||
if (outlawed) {
|
if (outlawed) {
|
||||||
return href
|
return href
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line
|
// If [text](url) was parsed as <a> and text is not empty and not a link itself,
|
||||||
return <Link id={props.id} target='_blank' rel={rel} href={href}>{children}</Link>
|
// we don't render it as an image since it was probably a conscious choice to include text.
|
||||||
|
const text = children[0]
|
||||||
|
let url
|
||||||
|
try {
|
||||||
|
url = !href.startsWith('/') && new URL(href)
|
||||||
|
} catch {
|
||||||
|
// ignore invalid URLs
|
||||||
|
}
|
||||||
|
|
||||||
|
const internalURL = process.env.NEXT_PUBLIC_URL
|
||||||
|
if (!!text && !/^https?:\/\//.test(text)) {
|
||||||
|
if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
id={props.id && itemId ? `${props.id}-${itemId}` : props.id}
|
||||||
|
href={itemId ? `${href}-${itemId}` : href}
|
||||||
|
>{text}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (text.startsWith?.('@')) {
|
||||||
|
// user mention might be within a markdown link like this: [@user foo bar](url)
|
||||||
|
const name = text.replace('@', '').split(' ')[0]
|
||||||
|
return (
|
||||||
|
<UserPopover name={name}>
|
||||||
|
<Link
|
||||||
|
id={props.id}
|
||||||
|
href={href}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Link>
|
||||||
|
</UserPopover>
|
||||||
|
)
|
||||||
|
} else if (href.startsWith('/') || url?.origin === internalURL) {
|
||||||
|
try {
|
||||||
|
const { linkText } = parseInternalLinks(href)
|
||||||
|
if (linkText) {
|
||||||
|
return (
|
||||||
|
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
||||||
|
<Link href={href}>{text}</Link>
|
||||||
|
</ItemPopover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore errors like invalid URLs
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
id={props.id}
|
||||||
|
href={href}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line
|
||||||
|
<a id={props.id} target='_blank' rel={rel ?? UNKNOWN_LINK_REL} href={href}>{text}</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { linkText } = parseInternalLinks(href)
|
||||||
|
if (linkText) {
|
||||||
|
return (
|
||||||
|
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
||||||
|
<Link href={href}>{linkText}</Link>
|
||||||
|
</ItemPopover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore errors like invalid URLs
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
img: TextMediaOrLink
|
||||||
embed: Embed
|
}), [outlawed, rel, itemId, Code, P, Heading, Table, TextMediaOrLink])
|
||||||
}), [outlawed, rel, TextMediaOrLink, topLevel])
|
|
||||||
|
|
||||||
const carousel = useCarousel()
|
const remarkPlugins = useMemo(() => [gfm, mention, sub], [])
|
||||||
|
const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], [])
|
||||||
const markdownContent = useMemo(() => (
|
|
||||||
<ReactMarkdown
|
|
||||||
components={components}
|
|
||||||
remarkPlugins={remarkPlugins}
|
|
||||||
rehypePlugins={rehypePlugins}
|
|
||||||
remarkRehypeOptions={{ clobberPrefix: `itemfn-${itemId}-` }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ReactMarkdown>
|
|
||||||
), [components, remarkPlugins, rehypePlugins, children, itemId])
|
|
||||||
|
|
||||||
const showOverflow = useCallback(() => setShow(true), [setShow])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={classNames(styles.text, topLevel && styles.topLevel, show ? styles.textUncontained : overflowing && styles.textContained)} ref={containerRef}>
|
||||||
className={classNames(
|
<ReactMarkdown
|
||||||
styles.text,
|
components={components}
|
||||||
topLevel && styles.topLevel,
|
remarkPlugins={remarkPlugins}
|
||||||
show ? styles.textUncontained : overflowing && styles.textContained
|
rehypePlugins={rehypePlugins}
|
||||||
)}
|
>
|
||||||
ref={containerRef}
|
{children}
|
||||||
>
|
</ReactMarkdown>
|
||||||
{
|
{overflowing && !show &&
|
||||||
carousel && tab !== 'preview'
|
<Button size='lg' variant='info' className={styles.textShowFull} onClick={() => setShow(true)}>
|
||||||
? markdownContent
|
|
||||||
: <CarouselProvider>{markdownContent}</CarouselProvider>
|
|
||||||
}
|
|
||||||
{overflowing && !show && (
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
variant='info'
|
|
||||||
className={styles.textShowFull}
|
|
||||||
onClick={showOverflow}
|
|
||||||
>
|
|
||||||
show full text
|
show full text
|
||||||
</Button>
|
</Button>}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}, isEqual)
|
}, isEqual)
|
||||||
|
|
||||||
function Mention ({ children, node, href, name, id }) {
|
|
||||||
return (
|
|
||||||
<UserPopover name={name}>
|
|
||||||
<Link
|
|
||||||
id={id}
|
|
||||||
href={href}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
</UserPopover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Sub ({ children, node, href, ...props }) {
|
|
||||||
return <Link href={href}>{children}</Link>
|
|
||||||
}
|
|
||||||
|
|
||||||
function Item ({ children, node, href, id }) {
|
|
||||||
return (
|
|
||||||
<ItemPopover id={id}>
|
|
||||||
<Link href={href}>{children}</Link>
|
|
||||||
</ItemPopover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Footnote ({ children, node, ...props }) {
|
|
||||||
return (
|
|
||||||
<Link {...props}>{children}</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MediaLink ({
|
|
||||||
node, src, outlawed, imgproxyUrls, rel = UNKNOWN_LINK_REL, ...props
|
|
||||||
}) {
|
|
||||||
const url = IMGPROXY_URL_REGEXP.test(src) ? decodeProxyUrl(src) : src
|
|
||||||
// if outlawed, render the media link as text
|
|
||||||
if (outlawed) {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
const srcSet = imgproxyUrls?.[url]
|
|
||||||
|
|
||||||
return <MediaOrLink srcSet={srcSet} src={src} rel={rel} {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function Table ({ node, ...props }) {
|
|
||||||
return (
|
|
||||||
<span className='table-responsive'>
|
|
||||||
<table className='table table-bordered table-sm' {...props} />
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Code ({ node, inline, className, children, style, ...props }) {
|
|
||||||
return inline
|
|
||||||
? (
|
|
||||||
<code className={className} {...props}>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<SyntaxHighlighter style={atomDark} language='text' PreTag='div' {...props}>
|
|
||||||
{children}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function P ({ children, node, onlyImages, somethingBefore, somethingAfter, ...props }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(styles.p, onlyImages && styles.onlyImages,
|
|
||||||
somethingBefore && styles.somethingBefore, somethingAfter && styles.somethingAfter)} {...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
@ -9,16 +9,11 @@
|
|||||||
--grid-gap: 0.5rem;
|
--grid-gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text.topLevel {
|
|
||||||
--grid-gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text :global(.footnotes) {
|
.text :global(.footnotes) {
|
||||||
font-size: smaller;
|
font-size: smaller;
|
||||||
color: #8b949e;
|
color: #8b949e;
|
||||||
border-top: 1px solid #30363d;
|
border-top: 1px solid #30363d;
|
||||||
margin-top: calc(var(--grid-gap)* 0.5);
|
|
||||||
padding-top: 0 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide the section label for visual users. */
|
/* Hide the section label for visual users. */
|
||||||
@ -42,12 +37,6 @@
|
|||||||
content: ']';
|
content: ']';
|
||||||
}
|
}
|
||||||
|
|
||||||
.text :global(sup:has([data-footnote-ref])) {
|
|
||||||
top: 0;
|
|
||||||
font-size: 100%;
|
|
||||||
vertical-align: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textUncontained {
|
.textUncontained {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
@ -121,18 +110,33 @@
|
|||||||
display: block;
|
display: block;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
padding-top: calc(var(--grid-gap) * 0.5);
|
padding-top: .25rem;
|
||||||
padding-bottom: calc(var(--grid-gap) * 0.5);
|
padding-bottom: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text.topLevel .p {
|
||||||
|
padding-top: .375rem;
|
||||||
|
padding-bottom: .375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text>*:not(.heading) {
|
.text>*:not(.heading) {
|
||||||
padding-top: calc(var(--grid-gap) * 0.5);
|
padding-top: .25rem;
|
||||||
padding-bottom: calc(var(--grid-gap) * 0.5);
|
padding-bottom: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text.topLevel>*:not(.heading) {
|
||||||
|
padding-top: .375rem;
|
||||||
|
padding-bottom: .375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text pre, .text blockquote {
|
.text pre, .text blockquote {
|
||||||
margin-top: calc(var(--grid-gap) * 0.5);
|
margin-top: .25rem;
|
||||||
margin-bottom: calc(var(--grid-gap) * 0.5);
|
margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text.topLevel pre, .text.topLevel blockquote {
|
||||||
|
margin-top: .375rem;
|
||||||
|
margin-bottom: .375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text pre>div {
|
.text pre>div {
|
||||||
@ -164,50 +168,50 @@
|
|||||||
|
|
||||||
.mediaContainer {
|
.mediaContainer {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: calc(100% - var(--grid-gap));
|
||||||
max-width: 100%;
|
max-width: calc(100% - var(--grid-gap));
|
||||||
height: auto;
|
height: auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-height: 25vh;
|
max-height: 25vh;
|
||||||
aspect-ratio: var(--aspect-ratio);
|
aspect-ratio: var(--aspect-ratio);
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mediaContainer.hasTextSiblingsBefore {
|
.p:has(> .mediaContainer) {
|
||||||
margin-top: var(--grid-gap);
|
white-space: normal;
|
||||||
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mediaContainer.hasTextSiblingsAfter {
|
.p:has(> .mediaContainer:only-child) ~ .p:has(> .mediaContainer:only-child),
|
||||||
margin-bottom: var(--grid-gap);
|
.p:has(> .mediaContainer:only-child):has(+ .p > .mediaContainer:only-child) {
|
||||||
}
|
display: inline-block;
|
||||||
|
|
||||||
.p:has(> .mediaContainer) .mediaContainer
|
|
||||||
{
|
|
||||||
display: flex;
|
|
||||||
width: min-content;
|
width: min-content;
|
||||||
max-width: 100%;
|
max-width: calc(100% - var(--grid-gap));
|
||||||
}
|
}
|
||||||
|
|
||||||
.p:has(> .mediaContainer) .mediaContainer img,
|
.mediaContainer ~ .mediaContainer, .mediaContainer:has(+ .mediaContainer) {
|
||||||
.p:has(> .mediaContainer) .mediaContainer video
|
display: inline-block;
|
||||||
{
|
width: min-content;
|
||||||
|
margin-right: var(--grid-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p:has(> .mediaContainer:only-child) ~ .p:has(> .mediaContainer:only-child) > .mediaContainer:only-child,
|
||||||
|
.p:has(> .mediaContainer:only-child):has(+ .p > .mediaContainer:only-child) > .mediaContainer:only-child,
|
||||||
|
.mediaContainer:first-child:has(+ .mediaContainer) {
|
||||||
|
margin-right: var(--grid-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p:has(> .mediaContainer:only-child) ~ .p:has(> .mediaContainer:only-child) > .mediaContainer:only-child img,
|
||||||
|
.p:has(> .mediaContainer:only-child):has(+ .p > .mediaContainer:only-child) > .mediaContainer:only-child img,
|
||||||
|
.mediaContainer ~ .mediaContainer img,
|
||||||
|
.mediaContainer:has(+ .mediaContainer) img {
|
||||||
block-size: revert-layer;
|
block-size: revert-layer;
|
||||||
max-width: stretch;
|
max-width: stretch;
|
||||||
}
|
}
|
||||||
|
.p:has(> .mediaContainer:only-child) ~ .p:has(> .mediaContainer:only-child) > .mediaContainer:only-child video,
|
||||||
.p.onlyImages {
|
.p:has(> .mediaContainer:only-child):has(+ .p > .mediaContainer:only-child) > .mediaContainer:only-child video,
|
||||||
display: flex;
|
.mediaContainer ~ .mediaContainer video,
|
||||||
flex-direction: row;
|
.mediaContainer:has(+ .mediaContainer) video {
|
||||||
flex-wrap: wrap;
|
block-size: stretch;
|
||||||
gap: var(--grid-gap);
|
|
||||||
}
|
|
||||||
|
|
||||||
.p.onlyImages:not(.somethingBefore) {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.p.onlyImages:not(.somethingAfter) {
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mediaContainer img, .mediaContainer video {
|
.mediaContainer img, .mediaContainer video {
|
||||||
@ -222,7 +226,6 @@
|
|||||||
.mediaContainer img {
|
.mediaContainer img {
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
min-width: 30%;
|
min-width: 30%;
|
||||||
max-width: 100%;
|
|
||||||
object-position: left top;
|
object-position: left top;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,6 +233,26 @@
|
|||||||
max-height: 35vh;
|
max-height: 35vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img.fullScreen {
|
||||||
|
cursor: zoom-out !important;
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullScreenContainer {
|
||||||
|
--bs-columns: 1;
|
||||||
|
--bs-rows: 1;
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
align-content: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.text table {
|
.text table {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
@ -265,31 +288,32 @@
|
|||||||
.text h1, .text h2, .text h3, .text h4, .text h5, .text h6 {
|
.text h1, .text h2, .text h3, .text h4, .text h5, .text h6 {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-size: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text h1 a, .text h2 a, .text h3 a, .text h4 a, .text h5 a, .text h6 a {
|
.text h1 {
|
||||||
text-decoration: none;
|
|
||||||
--bs-text-opacity: 1;
|
|
||||||
color: inherit !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topLevel.text h1 {
|
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topLevel.text h2 {
|
.text h2 {
|
||||||
font-size: 1.45rem;
|
font-size: 1.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topLevel.text h3 {
|
.text h3 {
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topLevel.text h4 {
|
.text h4 {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text h5 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text h6 {
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Utility classes used in rehype plugins in md.js */
|
/* Utility classes used in rehype plugins in md.js */
|
||||||
.subscript {
|
.subscript {
|
||||||
@ -302,10 +326,17 @@
|
|||||||
font-size: smaller;
|
font-size: smaller;
|
||||||
}
|
}
|
||||||
|
|
||||||
.twitterContainer, .nostrContainer, .videoWrapper, .wavlakeWrapper, .spotifyWrapper {
|
.twitterContainer, .nostrContainer, .videoWrapper, .wavlakeWrapper, .spotifyWrapper, .mediaContainer {
|
||||||
margin-top: calc(var(--grid-gap) * 0.5);
|
margin-top: 0.25rem;
|
||||||
margin-bottom: calc(var(--grid-gap) * 0.5);
|
margin-bottom: 0.25rem;
|
||||||
background-color: var(--theme-bg);
|
}
|
||||||
|
|
||||||
|
.topLevel .twitterContainer, .topLevel .nostrContainer, .topLevel .videoWrapper,
|
||||||
|
.topLevel .wavlakeWrapper, .topLevel .spotifyWrapper, .topLevel .mediaContainer,
|
||||||
|
:global(.topLevel) .twitterContainer, :global(.topLevel) .nostrContainer, :global(.topLevel) .videoWrapper,
|
||||||
|
:global(.topLevel) .wavlakeWrapper, :global(.topLevel) .spotifyWrapper, :global(.topLevel) .mediaContainer {
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.videoWrapper {
|
.videoWrapper {
|
||||||
|
@ -57,10 +57,7 @@ export function usePaidMutation (mutation,
|
|||||||
let { data, ...rest } = await mutate(innerOptions)
|
let { data, ...rest } = await mutate(innerOptions)
|
||||||
|
|
||||||
// use the most inner callbacks/options if they exist
|
// use the most inner callbacks/options if they exist
|
||||||
const {
|
const { onPaid, onPayError, forceWaitForPayment, persistOnNavigate, update } = { ...options, ...innerOptions }
|
||||||
onPaid, onPayError, forceWaitForPayment, persistOnNavigate,
|
|
||||||
update, waitFor = inv => inv?.actionState === 'PAID'
|
|
||||||
} = { ...options, ...innerOptions }
|
|
||||||
const ourOnCompleted = innerOnCompleted || onCompleted
|
const ourOnCompleted = innerOnCompleted || onCompleted
|
||||||
|
|
||||||
// get invoice without knowing the mutation name
|
// get invoice without knowing the mutation name
|
||||||
@ -98,7 +95,7 @@ export function usePaidMutation (mutation,
|
|||||||
// the action is pessimistic
|
// the action is pessimistic
|
||||||
try {
|
try {
|
||||||
// wait for the invoice to be paid
|
// wait for the invoice to be paid
|
||||||
await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor })
|
await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor: inv => inv?.actionState === 'PAID' })
|
||||||
if (!response.result) {
|
if (!response.result) {
|
||||||
// if the mutation didn't return any data, ie pessimistic, we need to fetch it
|
// if the mutation didn't return any data, ie pessimistic, we need to fetch it
|
||||||
const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } })
|
const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } })
|
||||||
|
39
lib/md.js
39
lib/md.js
@ -19,6 +19,45 @@ export function mdHas (md, test) {
|
|||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rehypeInlineCodeProperty () {
|
||||||
|
return function (tree) {
|
||||||
|
visit(tree, { tagName: 'code' }, function (node, index, parent) {
|
||||||
|
if (parent && parent.tagName === 'pre') {
|
||||||
|
node.properties.inline = false
|
||||||
|
} else {
|
||||||
|
node.properties.inline = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rehypeStyler (startTag, endTag, className) {
|
||||||
|
return function (tree) {
|
||||||
|
visit(tree, 'element', (node) => {
|
||||||
|
for (let i = 0; i < node.children.length; i += 1) {
|
||||||
|
const start = node.children[i]
|
||||||
|
const text = node.children[i + 1]
|
||||||
|
const end = node.children[i + 2]
|
||||||
|
|
||||||
|
// is this a children slice wrapped with the tags we're looking for?
|
||||||
|
const isWrapped =
|
||||||
|
start?.type === 'raw' && start?.value === startTag &&
|
||||||
|
text?.type === 'text' &&
|
||||||
|
end?.type === 'raw' && end?.value === endTag
|
||||||
|
if (!isWrapped) continue
|
||||||
|
|
||||||
|
const newChildren = {
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'span',
|
||||||
|
properties: { className: [className] },
|
||||||
|
children: [{ type: 'text', value: text.value }]
|
||||||
|
}
|
||||||
|
node.children.splice(i, 3, newChildren)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function extractUrls (md) {
|
export function extractUrls (md) {
|
||||||
if (!md) return []
|
if (!md) return []
|
||||||
const tree = fromMarkdown(md, {
|
const tree = fromMarkdown(md, {
|
||||||
|
277
lib/rehype-sn.js
277
lib/rehype-sn.js
@ -1,277 +0,0 @@
|
|||||||
import { SKIP, visit } from 'unist-util-visit'
|
|
||||||
import { parseEmbedUrl, parseInternalLinks } from './url'
|
|
||||||
import { slug } from 'github-slugger'
|
|
||||||
import { toString } from 'mdast-util-to-string'
|
|
||||||
|
|
||||||
const userGroup = '[\\w_]+'
|
|
||||||
const subGroup = '[A-Za-z][\\w_]+'
|
|
||||||
|
|
||||||
const mentionRegex = new RegExp('@(' + userGroup + '(?:\\/' + userGroup + ')?)', 'gi')
|
|
||||||
const subRegex = new RegExp('~(' + subGroup + '(?:\\/' + subGroup + ')?)', 'gi')
|
|
||||||
const nostrIdRegex = /\b((npub1|nevent1|nprofile1|note1|naddr1)[02-9ac-hj-np-z]+)\b/g
|
|
||||||
|
|
||||||
export default function rehypeSN (options = {}) {
|
|
||||||
const { stylers = [] } = options
|
|
||||||
|
|
||||||
return function transformer (tree) {
|
|
||||||
try {
|
|
||||||
visit(tree, (node, index, parent) => {
|
|
||||||
// Handle inline code property
|
|
||||||
if (node.tagName === 'code') {
|
|
||||||
node.properties.inline = !(parent && parent.tagName === 'pre')
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle headings
|
|
||||||
if (node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) && !node.properties.id) {
|
|
||||||
const nodeText = toString(node)
|
|
||||||
const headingId = slug(nodeText.replace(/[^\w\-\s]+/gi, ''))
|
|
||||||
node.properties.id = headingId
|
|
||||||
|
|
||||||
// Create a new link element
|
|
||||||
const linkElement = {
|
|
||||||
type: 'element',
|
|
||||||
tagName: 'headlink',
|
|
||||||
properties: {
|
|
||||||
href: `#${headingId}`
|
|
||||||
},
|
|
||||||
children: [{ type: 'text', value: nodeText }]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace the heading's children with the new link element
|
|
||||||
node.children = [linkElement]
|
|
||||||
return [SKIP]
|
|
||||||
}
|
|
||||||
|
|
||||||
// if img is wrapped in a link, remove the link
|
|
||||||
if (node.tagName === 'a' && node.children.length === 1 && node.children[0].tagName === 'img') {
|
|
||||||
parent.children[index] = node.children[0]
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle internal links
|
|
||||||
if (node.tagName === 'a') {
|
|
||||||
try {
|
|
||||||
if (node.properties.href.includes('#itemfn-')) {
|
|
||||||
node.tagName = 'footnote'
|
|
||||||
} else {
|
|
||||||
const { itemId, linkText } = parseInternalLinks(node.properties.href)
|
|
||||||
if (itemId) {
|
|
||||||
node.tagName = 'item'
|
|
||||||
node.properties.id = itemId
|
|
||||||
if (node.properties.href === toString(node)) {
|
|
||||||
node.children[0].value = linkText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore errors like invalid URLs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// only show a link as an embed if it doesn't have text siblings
|
|
||||||
if (node.tagName === 'a' &&
|
|
||||||
!parent.children.some(s => s.type === 'text' && s.value.trim()) &&
|
|
||||||
toString(node) === node.properties.href) {
|
|
||||||
const embed = parseEmbedUrl(node.properties.href)
|
|
||||||
if (embed) {
|
|
||||||
node.tagName = 'embed'
|
|
||||||
node.properties = { ...embed, src: node.properties.href }
|
|
||||||
} else {
|
|
||||||
node.tagName = 'autolink'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the link text is a URL, just show the URL
|
|
||||||
if (node.tagName === 'a' && isMisleadingLink(toString(node), node.properties.href)) {
|
|
||||||
node.children = [{ type: 'text', value: node.properties.href }]
|
|
||||||
return [SKIP]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle @mentions and ~subs
|
|
||||||
if (node.type === 'text') {
|
|
||||||
const newChildren = []
|
|
||||||
let lastIndex = 0
|
|
||||||
let match
|
|
||||||
let childrenConsumed = 1
|
|
||||||
let text = toString(node)
|
|
||||||
|
|
||||||
const combinedRegex = new RegExp(mentionRegex.source + '|' + subRegex.source, 'gi')
|
|
||||||
|
|
||||||
// handle @__username__ or ~__sub__
|
|
||||||
if (['@', '~'].includes(node.value) &&
|
|
||||||
parent.children[index + 1]?.tagName === 'strong' &&
|
|
||||||
parent.children[index + 1].children[0]?.type === 'text') {
|
|
||||||
childrenConsumed = 2
|
|
||||||
text = node.value + '__' + toString(parent.children[index + 1]) + '__'
|
|
||||||
}
|
|
||||||
|
|
||||||
while ((match = combinedRegex.exec(text)) !== null) {
|
|
||||||
if (lastIndex < match.index) {
|
|
||||||
newChildren.push({ type: 'text', value: text.slice(lastIndex, match.index) })
|
|
||||||
}
|
|
||||||
|
|
||||||
const [fullMatch, mentionMatch, subMatch] = match
|
|
||||||
const replacement = mentionMatch ? replaceMention(fullMatch, mentionMatch) : replaceSub(fullMatch, subMatch)
|
|
||||||
|
|
||||||
if (replacement) {
|
|
||||||
newChildren.push(replacement)
|
|
||||||
} else {
|
|
||||||
newChildren.push({ type: 'text', value: fullMatch })
|
|
||||||
}
|
|
||||||
|
|
||||||
lastIndex = combinedRegex.lastIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newChildren.length > 0) {
|
|
||||||
if (lastIndex < text.length) {
|
|
||||||
newChildren.push({ type: 'text', value: text.slice(lastIndex) })
|
|
||||||
}
|
|
||||||
parent.children.splice(index, childrenConsumed, ...newChildren)
|
|
||||||
return index + newChildren.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Nostr IDs
|
|
||||||
if (node.type === 'text') {
|
|
||||||
const newChildren = []
|
|
||||||
let lastIndex = 0
|
|
||||||
let match
|
|
||||||
|
|
||||||
while ((match = nostrIdRegex.exec(node.value)) !== null) {
|
|
||||||
if (lastIndex < match.index) {
|
|
||||||
newChildren.push({ type: 'text', value: node.value.slice(lastIndex, match.index) })
|
|
||||||
}
|
|
||||||
|
|
||||||
newChildren.push(replaceNostrId(match[0], match[0]))
|
|
||||||
|
|
||||||
lastIndex = nostrIdRegex.lastIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < node.value.length) {
|
|
||||||
newChildren.push({ type: 'text', value: node.value.slice(lastIndex) })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newChildren.length > 0) {
|
|
||||||
parent.children.splice(index, 1, ...newChildren)
|
|
||||||
return index + newChildren.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle custom tags
|
|
||||||
if (node.type === 'element') {
|
|
||||||
for (const { startTag, endTag, className } of stylers) {
|
|
||||||
for (let i = 0; i < node.children.length - 2; i++) {
|
|
||||||
const [start, text, end] = node.children.slice(i, i + 3)
|
|
||||||
|
|
||||||
if (start?.type === 'raw' && start?.value === startTag &&
|
|
||||||
text?.type === 'text' &&
|
|
||||||
end?.type === 'raw' && end?.value === endTag) {
|
|
||||||
const newChild = {
|
|
||||||
type: 'element',
|
|
||||||
tagName: 'span',
|
|
||||||
properties: { className: [className] },
|
|
||||||
children: [{ type: 'text', value: text.value }]
|
|
||||||
}
|
|
||||||
node.children.splice(i, 3, newChild)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// merge adjacent images and empty paragraphs into a single image collage
|
|
||||||
if ((node.tagName === 'img' || isImageOnlyParagraph(node)) && Array.isArray(parent.children)) {
|
|
||||||
const adjacentNodes = [node]
|
|
||||||
let nextIndex = index + 1
|
|
||||||
const siblings = parent.children
|
|
||||||
const somethingBefore = parent.children[index - 1] && parent.children[index - 1].tagName !== 'p'
|
|
||||||
let somethingAfter = false
|
|
||||||
|
|
||||||
while (nextIndex < siblings.length) {
|
|
||||||
const nextNode = siblings[nextIndex]
|
|
||||||
if (!nextNode) break
|
|
||||||
if (nextNode.tagName === 'img' || isImageOnlyParagraph(nextNode)) {
|
|
||||||
adjacentNodes.push(nextNode)
|
|
||||||
nextIndex++
|
|
||||||
} else if (nextNode.type === 'text' && typeof nextNode.value === 'string' && !nextNode.value.trim()) {
|
|
||||||
nextIndex++
|
|
||||||
} else {
|
|
||||||
somethingAfter = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adjacentNodes.length > 0) {
|
|
||||||
const allImages = adjacentNodes.flatMap(n =>
|
|
||||||
n.tagName === 'img' ? [n] : (Array.isArray(n.children) ? n.children.filter(child => child.tagName === 'img') : [])
|
|
||||||
)
|
|
||||||
const collageNode = {
|
|
||||||
type: 'element',
|
|
||||||
tagName: 'p',
|
|
||||||
children: allImages,
|
|
||||||
properties: { onlyImages: true, somethingBefore, somethingAfter }
|
|
||||||
}
|
|
||||||
parent.children.splice(index, nextIndex - index, collageNode)
|
|
||||||
return index + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in rehypeSN transformer:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tree
|
|
||||||
}
|
|
||||||
|
|
||||||
function isImageOnlyParagraph (node) {
|
|
||||||
return node &&
|
|
||||||
node.tagName === 'p' &&
|
|
||||||
Array.isArray(node.children) &&
|
|
||||||
node.children.every(child =>
|
|
||||||
(child.tagName === 'img') ||
|
|
||||||
(child.type === 'text' && typeof child.value === 'string' && !child.value.trim())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceMention (value, username) {
|
|
||||||
return {
|
|
||||||
type: 'element',
|
|
||||||
tagName: 'mention',
|
|
||||||
properties: { href: '/' + username, name: username },
|
|
||||||
children: [{ type: 'text', value }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceSub (value, sub) {
|
|
||||||
return {
|
|
||||||
type: 'element',
|
|
||||||
tagName: 'sub',
|
|
||||||
properties: { href: '/~' + sub, name: sub },
|
|
||||||
children: [{ type: 'text', value }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMisleadingLink (text, href) {
|
|
||||||
let misleading = false
|
|
||||||
|
|
||||||
if (/^\s*(\w+\.)+\w+/.test(text)) {
|
|
||||||
try {
|
|
||||||
const hrefUrl = new URL(href)
|
|
||||||
|
|
||||||
if (new URL(hrefUrl.protocol + text).origin !== hrefUrl.origin) {
|
|
||||||
misleading = true
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return misleading
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceNostrId (value, id) {
|
|
||||||
return {
|
|
||||||
type: 'element',
|
|
||||||
tagName: 'a',
|
|
||||||
properties: { href: `https://njump.me/${id}` },
|
|
||||||
children: [{ type: 'text', value }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
38
lib/remark-mention.js
Normal file
38
lib/remark-mention.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { findAndReplace } from 'mdast-util-find-and-replace'
|
||||||
|
|
||||||
|
const userGroup = '[\\w_]+'
|
||||||
|
|
||||||
|
const mentionRegex = new RegExp(
|
||||||
|
'@(' + userGroup + '(?:\\/' + userGroup + ')?)',
|
||||||
|
'gi'
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function mention (options) {
|
||||||
|
return function transformer (tree) {
|
||||||
|
findAndReplace(
|
||||||
|
tree,
|
||||||
|
[
|
||||||
|
[mentionRegex, replaceMention]
|
||||||
|
],
|
||||||
|
{ ignore: ['link', 'linkReference'] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceMention (value, username, match) {
|
||||||
|
if (
|
||||||
|
/[\w`]/.test(match.input.charAt(match.index - 1)) ||
|
||||||
|
/[/\w`]/.test(match.input.charAt(match.index + value.length))
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = { type: 'text', value }
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'link',
|
||||||
|
title: null,
|
||||||
|
url: '/' + username,
|
||||||
|
children: [node]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
38
lib/remark-sub.js
Normal file
38
lib/remark-sub.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { findAndReplace } from 'mdast-util-find-and-replace'
|
||||||
|
|
||||||
|
const subGroup = '[A-Za-z][\\w_]+'
|
||||||
|
|
||||||
|
const subRegex = new RegExp(
|
||||||
|
'~(' + subGroup + '(?:\\/' + subGroup + ')?)',
|
||||||
|
'gi'
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function sub (options) {
|
||||||
|
return function transformer (tree) {
|
||||||
|
findAndReplace(
|
||||||
|
tree,
|
||||||
|
[
|
||||||
|
[subRegex, replaceSub]
|
||||||
|
],
|
||||||
|
{ ignore: ['link', 'linkReference'] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceSub (value, sub, match) {
|
||||||
|
if (
|
||||||
|
/[\w`]/.test(match.input.charAt(match.index - 1)) ||
|
||||||
|
/[/\w`]/.test(match.input.charAt(match.index + value.length))
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = { type: 'text', value }
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'link',
|
||||||
|
title: null,
|
||||||
|
url: '/~' + sub,
|
||||||
|
children: [node]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -177,7 +177,7 @@ export function parseEmbedUrl (href) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('Error parsing embed URL:', href)
|
console.error('Error parsing embed URL:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
@ -263,20 +263,6 @@ $zindex-sticky: 900;
|
|||||||
justify-self: center;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pulse {
|
|
||||||
animation-name: pulse;
|
|
||||||
animation-iteration-count: infinite;
|
|
||||||
animation-timing-function: ease-in-out;
|
|
||||||
animation-duration: 0.66s;
|
|
||||||
animation-direction: alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
opacity: 42%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: var(--bs-body-color);
|
fill: var(--bs-body-color);
|
||||||
}
|
}
|
||||||
@ -940,10 +926,6 @@ div[contenteditable]:focus,
|
|||||||
animation: flipX 2s linear infinite;
|
animation: flipX 2s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topLevel {
|
|
||||||
--grid-gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes flipY {
|
@keyframes flipY {
|
||||||
from {
|
from {
|
||||||
transform: rotateY(0deg);
|
transform: rotateY(0deg);
|
||||||
|
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16.1716 10.9999L10.8076 5.63589L12.2218 4.22168L20 11.9999L12.2218 19.778L10.8076 18.3638L16.1716 12.9999H4V10.9999H16.1716Z"></path></svg>
|
|
Before Width: | Height: | Size: 229 B |
@ -104,6 +104,10 @@ async function work () {
|
|||||||
await boss.work('paidActionCanceling', jobWrapper(paidActionCanceling))
|
await boss.work('paidActionCanceling', jobWrapper(paidActionCanceling))
|
||||||
await boss.work('paidActionFailed', jobWrapper(paidActionFailed))
|
await boss.work('paidActionFailed', jobWrapper(paidActionFailed))
|
||||||
await boss.work('paidActionPaid', jobWrapper(paidActionPaid))
|
await boss.work('paidActionPaid', jobWrapper(paidActionPaid))
|
||||||
|
// we renamed these jobs so we leave them so they can "migrate"
|
||||||
|
await boss.work('holdAction', jobWrapper(paidActionHeld))
|
||||||
|
await boss.work('settleActionError', jobWrapper(paidActionFailed))
|
||||||
|
await boss.work('settleAction', jobWrapper(paidActionPaid))
|
||||||
}
|
}
|
||||||
if (isServiceEnabled('search')) {
|
if (isServiceEnabled('search')) {
|
||||||
await boss.work('indexItem', jobWrapper(indexItem))
|
await boss.work('indexItem', jobWrapper(indexItem))
|
||||||
|
Loading…
x
Reference in New Issue
Block a user