Compare commits

..

15 Commits

Author SHA1 Message Date
k00b 2e1a7c1035 don't allow other markup in headings 2024-09-29 11:25:01 -05:00
k00b bbdc37ffd4 fix missing src on autolinks 2024-09-29 11:13:33 -05:00
k00b 6f198fd1ca dark mode for nostr embed 2024-09-28 20:02:33 -05:00
k00b 4f158a98a8 fix link item embed regression 2024-09-28 19:17:15 -05:00
k00b 80a7c24e54 recognize nostr entities 2024-09-28 18:56:37 -05:00
k00b f7010ee3f7 fix #663 2024-09-28 18:20:33 -05:00
Keyan cc4bbf99e4
fixes #1395 (#1430)
* fixes #1395

* rehype plugin for embeds

* fix lint

* replace many plugins with one rehype and improve image collage

* remove unused css

* handle more custom markdown behavior in rehype

* refactor markdown rendering more + better footnotes

* move more markdown logic to reyhpe plugin + better headers

* fix #1397

* refactor embeds out of media-or-link
2024-09-28 16:33:07 -05:00
Keyan 0f9b6f02f6
Update awards.csv 2024-09-26 18:41:24 -05:00
ekzyis 9f79d588a8
Image carousel (#1425)
* Add image carousel in fullscreen

* Flip through all images of a post

* Disable image selection in fullscreen

* Keep max-width: 100vw for images

* Fix missing dependency

* fix merge resolve bug

* better css

* refactor, keypress/swipe events, remove scoll

* changes after self-review

* give previews their own carousel

* hooks for arrow keys and swiping

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-09-26 17:37:13 -05:00
k00b 5371e1abf8 fix bounty paid action notification 2024-09-26 09:53:25 -05:00
k00b 0d4c8cdc23 no optimistic item repetition for anon 2024-09-26 09:44:14 -05:00
k00b b3c8d32d5a remove debug log comment 2024-09-25 18:04:03 -05:00
k00b 3f49c93ecb update item repetition optimistically 2024-09-25 18:02:40 -05:00
k00b 68f7e4111b prevent shift on pending -> paid 2024-09-25 17:38:37 -05:00
Keyan 9f06fd65ee
UX latency enhancements for paid actions (#1434)
* prevent multiple retries & pulse retry button

* fix lint

* don't wait for settlement on pessimistic zaps

* optimistic act modal
2024-09-25 13:32:52 -05:00
29 changed files with 1181 additions and 779 deletions

View File

@ -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 { invoiceId } = args const { invoice: failedInvoice } = args
console.log('retryPaidAction', actionType, args) console.log('retryPaidAction', actionType, args)
@ -181,18 +181,13 @@ 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 (!invoiceId) { if (!failedInvoice) {
throw new Error(`retryPaidAction - missing invoiceId ${actionType}`) throw new Error(`retryPaidAction - missing invoice ${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
@ -204,7 +199,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: invoiceId, id: failedInvoice.id,
actionState: 'FAILED' actionState: 'FAILED'
}, },
data: { data: {
@ -216,7 +211,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, newInvoiceId: invoice.id }, context), result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
invoice, invoice,
paymentMethod: 'OPTIMISTIC' paymentMethod: 'OPTIMISTIC'
} }

View File

@ -56,7 +56,14 @@ export default {
throw new Error('Invoice not found') throw new Error('Invoice not found')
} }
const result = await retryPaidAction(invoice.actionType, { invoiceId }, { models, me, lnd }) if (invoice.actionState !== 'FAILED') {
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,

View File

@ -128,3 +128,4 @@ 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 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
128 SatsAllDay issue #1368 #1331 medium 25k weareallsatoshi@getalby.com 2024-09-16
129 benalleng helpfulness #1368 #1170 medium did a lot of it in #1175 25k BenAllenG@stacker.news 2024-09-16
130 humble-GOAT issue #1412 #1407 good-first-issue 2k humble_GOAT@stacker.news 2024-09-18
131 felipebueno issue #1425 #986 medium 25k felipebueno@getalby.com 2024-09-26

133
components/carousel.js Normal file
View File

@ -0,0 +1,133 @@
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)
}

View File

@ -0,0 +1,63 @@
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;
}

View File

@ -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 rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry
}) { }) {
const [edit, setEdit] = useState() const [edit, setEdit] = useState()
const { me } = useMe() const { me } = useMe()
@ -169,6 +169,8 @@ 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} />}

215
components/embed.js Normal file
View File

@ -0,0 +1,215 @@
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

View File

@ -50,7 +50,6 @@ 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></>,

View File

@ -53,7 +53,7 @@ export function SubmitButton ({
return ( return (
<Button <Button
variant={variant || 'main'} variant={variant || 'main'}
className={classNames(formik.isSubmitting && styles.pending, className)} className={classNames(formik.isSubmitting && 'pulse', 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} noFragments tab={tab}>{meta.value}</Text> <Text topLevel={topLevel} tab={tab}>{meta.value}</Text>
</div> </div>
</div>} </div>}
</div> </div>

View File

@ -13,8 +13,12 @@ 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 ({ id, query = INVOICE, modal, onPayment, onCanceled, info, successVerb, useWallet = true, walletError, poll, waitFor, ...props }) { export default function Invoice ({
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
? {} ? {}
@ -55,9 +59,17 @@ export default function Invoice ({ id, query = INVOICE, modal, onPayment, onCanc
variant = 'failed' variant = 'failed'
status = 'cancelled' status = 'cancelled'
useWallet = false useWallet = false
} else if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived && !expired)) { } else if (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 || 'deposited'}` status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb}`
useWallet = false useWallet = false
} else if (expired) { } else if (expired) {
variant = 'failed' variant = 'failed'

View File

@ -90,6 +90,7 @@ 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(() => {
@ -110,6 +111,18 @@ 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,
@ -127,15 +140,11 @@ 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
onCompleted: () => { onPaid: closeImmediately ? undefined : onPaid
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, act, item.id, onClose, abortSignal, strike]) }, [me, actor, !!wallet, 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>
@ -226,6 +235,7 @@ 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)

View File

@ -19,11 +19,13 @@ 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 } from '@/lib/url' import { decodeProxyUrl, IMGPROXY_URL_REGEXP, parseEmbedUrl } 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()
@ -49,10 +51,18 @@ function BioItem ({ item, handleClick }) {
} }
function ItemEmbed ({ url, imgproxyUrls }) { function ItemEmbed ({ url, imgproxyUrls }) {
const src = IMGPROXY_URL_REGEXP.test(url) ? decodeProxyUrl(url) : url if (imgproxyUrls) {
const srcSet = imgproxyUrls?.[url] const src = IMGPROXY_URL_REGEXP.test(url) ? decodeProxyUrl(url) : url
const srcSet = imgproxyUrls?.[url]
return <MediaOrLink src={src} srcSet={srcSet} topLevel linkFallback={false} />
}
return <MediaOrLink src={src} srcSet={srcSet} topLevel linkFallback={false} /> const provider = parseEmbedUrl(url)
if (provider) {
return <Embed url={url} {...provider} topLevel />
}
return null
} }
function FwdUsers ({ forwards }) { function FwdUsers ({ forwards }) {
@ -156,20 +166,22 @@ export default function ItemFull ({ item, bio, rank, ...props }) {
</div>) </div>)
: <div />} : <div />}
<RootProvider root={item.root || item}> <RootProvider root={item.root || item}>
{item.parentId <CarouselProvider key={item.id}>
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} /> {item.parentId
: ( ? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
<div>{bio : (
? <BioItem item={item} {...props} /> <div>{bio
: <TopLevelItem item={item} {...props} />} ? <BioItem item={item} {...props} />
</div>)} : <TopLevelItem item={item} {...props} />}
{item.comments && </div>)}
<div className={styles.comments}> {item.comments &&
<Comments <div className={styles.comments}>
parentId={item.id} parentCreatedAt={item.createdAt} <Comments
pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments} parentId={item.id} parentCreatedAt={item.createdAt}
/> pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments}
</div>} />
</div>}
</CarouselProvider>
</RootProvider> </RootProvider>
</> </>
) )

View File

@ -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,72 +64,6 @@ 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) &&
@ -217,8 +151,11 @@ export default function ItemInfo ({
{ {
showActionDropdown && showActionDropdown &&
<> <>
<EditInfo /> <EditInfo
<PaymentInfo /> item={item} canEdit={canEdit}
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} />
@ -317,3 +254,86 @@ 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
}

View File

@ -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 onQuoteReply, pinnable, setDisableRetry, disableRetry
}) { }) {
const titleRef = useRef() const titleRef = useRef()
const router = useRouter() const router = useRouter()
@ -139,6 +139,8 @@ 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>

View File

@ -1,14 +1,10 @@
import styles from './text.module.css' import styles from './text.module.css'
import { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react' import { useState, useEffect, useMemo, useCallback, memo } from 'react'
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP, parseEmbedUrl } from '@/lib/url' import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP } 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 { TwitterTweetEmbed } from 'react-twitter-embed' import { useCarousel } from './carousel'
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])
@ -23,9 +19,15 @@ function LinkRaw ({ href, children, src, rel }) {
) )
} }
const Media = memo(function Media ({ src, bestResSrc, srcSet, sizes, width, height, onClick, onError, style, className, video }) { const Media = memo(function Media ({
src, bestResSrc, srcSet, sizes, width,
height, onClick, onError, style, className, video
}) {
return ( return (
<div className={classNames(className, styles.mediaContainer)} style={style}> <div
className={classNames(className, styles.mediaContainer)}
style={style}
>
{video {video
? <video ? <video
src={src} src={src}
@ -52,32 +54,21 @@ const Media = memo(function Media ({ src, bestResSrc, srcSet, sizes, width, heig
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 showModal = useShowModal() const { showCarousel, addMedia, removeMedia } = useCarousel()
const handleClick = useCallback(() => showModal(close => { useEffect(() => {
return ( if (!media.image) return
<div addMedia({ src: media.bestResSrc, originalSrc: media.originalSrc, rel: props.rel })
className={styles.fullScreenContainer} }, [media.image])
onClick={close}
> const handleClick = useCallback(() => showCarousel({ src: media.bestResSrc }),
<img className={styles.fullScreen} src={media.bestResSrc} /> [showCarousel, 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]) }, [setError, removeMedia, media.bestResSrc])
if (!media.src) return null if (!media.src) return null
@ -89,14 +80,6 @@ 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) {
@ -114,11 +97,10 @@ 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 || embed) return if (!showMedia || isVideo || isImage) 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)
@ -133,7 +115,7 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
video.onloadeddata = null video.onloadeddata = null
video.src = '' video.src = ''
} }
}, [src, setIsImage, setIsVideo, showMedia, isVideo, embed]) }, [src, setIsImage, setIsVideo, showMedia, isVideo])
const srcSet = useMemo(() => { const srcSet = useMemo(() => {
if (Object.keys(srcSetObj).length === 0) return undefined if (Object.keys(srcSetObj).length === 0) return undefined
@ -182,203 +164,7 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
style, style,
width, width,
height, height,
image: (!me?.privates?.imgproxyOnly || trusted) && showMedia && isImage && !isVideo && !embed, image: (!me?.privates?.imgproxyOnly || trusted) && showMedia && isImage && !isVideo,
video: !me?.privates?.imgproxyOnly && showMedia && isVideo && !embed, video: !me?.privates?.imgproxyOnly && showMedia && isVideo
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
})

View File

@ -36,6 +36,14 @@ 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) {
@ -94,7 +102,7 @@ export default function useModal () {
const showModal = useCallback( const showModal = useCallback(
(getContent, options) => { (getContent, options) => {
const ref = { node: getContent(onClose), options } const ref = { node: getContent(onClose, setOptions), options }
if (options?.replaceModal) { if (options?.replaceModal) {
modalStack.current = [ref] modalStack.current = [ref]
} else { } else {

View File

@ -38,6 +38,7 @@ 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
@ -102,14 +103,14 @@ function NoteHeader ({ color, children, big }) {
) )
} }
function NoteItem ({ item }) { function NoteItem ({ item, ...props }) {
return ( return (
<div> <div>
{item.title {item.title
? <Item item={item} itemClassName='pt-0' /> ? <Item item={item} itemClassName='pt-0' {...props} />
: ( : (
<RootProvider root={item.root}> <RootProvider root={item.root}>
<Comment item={item} noReply includeParent clickToContext /> <Comment item={item} noReply includeParent clickToContext {...props} />
</RootProvider>)} </RootProvider>)}
</div> </div>
) )
@ -343,7 +344,10 @@ function InvoicePaid ({ n }) {
} }
function useActRetry ({ invoice }) { function useActRetry ({ invoice }) {
const bountyCacheMods = invoice.item?.bounty ? payBountyCacheMods() : {} const bountyCacheMods =
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 }) => {
@ -383,6 +387,7 @@ 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
@ -407,7 +412,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'
@ -443,14 +448,19 @@ 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='outline-warning ms-2 border-1 rounded py-0' size='sm' variant={classNames('outline-warning ms-2 border-1 rounded py-0', disableRetry && 'pulse')}
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)
} }
}} }}
> >
@ -459,7 +469,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} /> <NoteItem item={invoice.item} setDisableRetry={setDisableRetry} disableRetry={disableRetry} />
</div> </div>
) )
} }

View File

@ -14,6 +14,7 @@ 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)
@ -80,6 +81,17 @@ 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

View File

@ -3,18 +3,10 @@ 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, parseInternalLinks, decodeProxyUrl } from '@/lib/url' import { IMGPROXY_URL_REGEXP, 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'
@ -23,10 +15,24 @@ 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'
// Explicitely defined start/end tags & which CSS class from text.module.css to apply const rehypeSNStyled = () => rehypeSN({
export const rehypeSuperscript = () => rehypeStyler('<sup>', '</sup>', styles.superscript) stylers: [{
export const rehypeSubscript = () => rehypeStyler('<sub>', '</sub>', styles.subscript) startTag: '<sup>',
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 (
@ -41,16 +47,17 @@ 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, imgproxyUrls, children, tab, itemId, outlawed, topLevel, noFragments }) { export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, children, tab, itemId, outlawed, topLevel }) {
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('#')) setShow(router.asPath.includes('#') && !router.asPath.includes('#itemfn-'))
const handleRouteChange = (url, { shallow }) => { const handleRouteChange = (url, { shallow }) => {
setShow(url.includes('#')) setShow(url.includes('#') && !url.includes('#itemfn-'))
} }
router.events.on('hashChangeStart', handleRouteChange) router.events.on('hashChangeStart', handleRouteChange)
@ -58,8 +65,9 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
return () => { return () => {
router.events.off('hashChangeStart', handleRouteChange) router.events.off('hashChangeStart', handleRouteChange)
} }
}, [router]) }, [router.asPath, router.events])
// 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
@ -82,199 +90,157 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
} }
}, [containerRef.current, setOverflowing]) }, [containerRef.current, setOverflowing])
const Heading = useCallback(({ children, node, ...props }) => { const TextMediaOrLink = useCallback(props => {
const [copied, setCopied] = useState(false) return <MediaLink {...props} outlawed={outlawed} imgproxyUrls={imgproxyUrls} topLevel={topLevel} rel={rel} />
const nodeText = toString(node) },
const id = useMemo(() => noFragments ? undefined : slug(nodeText.replace(/[^\w\-\s]+/gi, '')), [nodeText, noFragments]) [outlawed, imgproxyUrls, topLevel, rel])
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: Heading, h1: ({ node, id, ...props }) => <h1 id={topLevel ? id : undefined} {...props} />,
h2: Heading, h2: ({ node, id, ...props }) => <h2 id={topLevel ? id : undefined} {...props} />,
h3: Heading, h3: ({ node, id, ...props }) => <h3 id={topLevel ? id : undefined} {...props} />,
h4: Heading, h4: ({ node, id, ...props }) => <h4 id={topLevel ? id : undefined} {...props} />,
h5: Heading, h5: ({ node, id, ...props }) => <h5 id={topLevel ? id : undefined} {...props} />,
h6: Heading, h6: ({ node, id, ...props }) => <h6 id={topLevel ? id : undefined} {...props} />,
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
} }
// If [text](url) was parsed as <a> and text is not empty and not a link itself, // eslint-disable-next-line
// we don't render it as an image since it was probably a conscious choice to include text. return <Link id={props.id} target='_blank' rel={rel} href={href}>{children}</Link>
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,
}), [outlawed, rel, itemId, Code, P, Heading, Table, TextMediaOrLink]) embed: Embed
}), [outlawed, rel, TextMediaOrLink, topLevel])
const remarkPlugins = useMemo(() => [gfm, mention, sub], []) const carousel = useCarousel()
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 className={classNames(styles.text, topLevel && styles.topLevel, show ? styles.textUncontained : overflowing && styles.textContained)} ref={containerRef}> <div
<ReactMarkdown className={classNames(
components={components} styles.text,
remarkPlugins={remarkPlugins} topLevel && styles.topLevel,
rehypePlugins={rehypePlugins} show ? styles.textUncontained : overflowing && styles.textContained
> )}
{children} ref={containerRef}
</ReactMarkdown> >
{overflowing && !show && {
<Button size='lg' variant='info' className={styles.textShowFull} onClick={() => setShow(true)}> carousel && tab !== 'preview'
? 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>
)
}

View File

@ -9,11 +9,16 @@
--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. */
@ -37,6 +42,12 @@
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;
} }
@ -110,33 +121,18 @@
display: block; display: block;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
padding-top: .25rem; padding-top: calc(var(--grid-gap) * 0.5);
padding-bottom: .25rem; padding-bottom: calc(var(--grid-gap) * 0.5);
}
.text.topLevel .p {
padding-top: .375rem;
padding-bottom: .375rem;
} }
.text>*:not(.heading) { .text>*:not(.heading) {
padding-top: .25rem; padding-top: calc(var(--grid-gap) * 0.5);
padding-bottom: .25rem; padding-bottom: calc(var(--grid-gap) * 0.5);
}
.text.topLevel>*:not(.heading) {
padding-top: .375rem;
padding-bottom: .375rem;
} }
.text pre, .text blockquote { .text pre, .text blockquote {
margin-top: .25rem; margin-top: calc(var(--grid-gap) * 0.5);
margin-bottom: .25rem; margin-bottom: calc(var(--grid-gap) * 0.5);
}
.text.topLevel pre, .text.topLevel blockquote {
margin-top: .375rem;
margin-bottom: .375rem;
} }
.text pre>div { .text pre>div {
@ -168,50 +164,50 @@
.mediaContainer { .mediaContainer {
display: block; display: block;
width: calc(100% - var(--grid-gap)); width: 100%;
max-width: calc(100% - var(--grid-gap)); max-width: 100%;
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;
} }
.p:has(> .mediaContainer) { .mediaContainer.hasTextSiblingsBefore {
white-space: normal; margin-top: var(--grid-gap);
padding: 0 !important;
} }
.p:has(> .mediaContainer:only-child) ~ .p:has(> .mediaContainer:only-child), .mediaContainer.hasTextSiblingsAfter {
.p:has(> .mediaContainer:only-child):has(+ .p > .mediaContainer:only-child) { margin-bottom: var(--grid-gap);
display: inline-block; }
.p:has(> .mediaContainer) .mediaContainer
{
display: flex;
width: min-content; width: min-content;
max-width: calc(100% - var(--grid-gap)); max-width: 100%;
} }
.mediaContainer ~ .mediaContainer, .mediaContainer:has(+ .mediaContainer) { .p:has(> .mediaContainer) .mediaContainer img,
display: inline-block; .p:has(> .mediaContainer) .mediaContainer video
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:has(> .mediaContainer:only-child):has(+ .p > .mediaContainer:only-child) > .mediaContainer:only-child video, .p.onlyImages {
.mediaContainer ~ .mediaContainer video, display: flex;
.mediaContainer:has(+ .mediaContainer) video { flex-direction: row;
block-size: stretch; flex-wrap: wrap;
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 {
@ -226,6 +222,7 @@
.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;
} }
@ -233,26 +230,6 @@
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;
} }
@ -288,30 +265,29 @@ img.fullScreen {
.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;
}
.text h1 {
font-size: 1.6rem;
}
.text h2 {
font-size: 1.45rem;
}
.text h3 {
font-size: 1.3rem;
}
.text h4 {
font-size: 1.15rem;
}
.text h5 {
font-size: 1rem; font-size: 1rem;
} }
.text h6 { .text h1 a, .text h2 a, .text h3 a, .text h4 a, .text h5 a, .text h6 a {
font-size: .85rem; text-decoration: none;
--bs-text-opacity: 1;
color: inherit !important;
}
.topLevel.text h1 {
font-size: 1.6rem;
}
.topLevel.text h2 {
font-size: 1.45rem;
}
.topLevel.text h3 {
font-size: 1.3rem;
}
.topLevel.text h4 {
font-size: 1.15rem;
} }
@ -326,17 +302,10 @@ img.fullScreen {
font-size: smaller; font-size: smaller;
} }
.twitterContainer, .nostrContainer, .videoWrapper, .wavlakeWrapper, .spotifyWrapper, .mediaContainer { .twitterContainer, .nostrContainer, .videoWrapper, .wavlakeWrapper, .spotifyWrapper {
margin-top: 0.25rem; margin-top: calc(var(--grid-gap) * 0.5);
margin-bottom: 0.25rem; margin-bottom: calc(var(--grid-gap) * 0.5);
} 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 {

View File

@ -57,7 +57,10 @@ 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 { onPaid, onPayError, forceWaitForPayment, persistOnNavigate, update } = { ...options, ...innerOptions } const {
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
@ -95,7 +98,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: inv => inv?.actionState === 'PAID' }) await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor })
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) } })

View File

@ -19,45 +19,6 @@ 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 Normal file
View File

@ -0,0 +1,277 @@
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 }]
}
}
}

View File

@ -1,38 +0,0 @@
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]
}
}
}

View File

@ -1,38 +0,0 @@
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]
}
}
}

View File

@ -177,7 +177,7 @@ export function parseEmbedUrl (href) {
} }
} }
} catch (err) { } catch (err) {
console.error('Error parsing embed URL:', err) console.log('Error parsing embed URL:', href)
} }
return null return null

View File

@ -263,6 +263,20 @@ $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);
} }
@ -926,6 +940,10 @@ 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);

View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 229 B

View File

@ -104,10 +104,6 @@ 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))