Compare commits
9 Commits
9c5bec06fb
...
033270b6ae
Author | SHA1 | Date | |
---|---|---|---|
|
033270b6ae | ||
|
94cce9155d | ||
|
b81c5bcc78 | ||
|
d2daad5b20 | ||
|
dc59153663 | ||
|
26fe7fce33 | ||
|
30e24bfe47 | ||
|
61852523fc | ||
|
52f57f8ac5 |
@ -899,7 +899,7 @@ export default {
|
|||||||
WHERE act IN ('TIP', 'FEE')
|
WHERE act IN ('TIP', 'FEE')
|
||||||
AND "itemId" = ${Number(id)}::INTEGER
|
AND "itemId" = ${Number(id)}::INTEGER
|
||||||
AND "userId" = ${me.id}::INTEGER)::INTEGER)`,
|
AND "userId" = ${me.id}::INTEGER)::INTEGER)`,
|
||||||
{ models }
|
{ models, lnd, hash, hmac }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
await serialize(
|
await serialize(
|
||||||
|
@ -99,3 +99,8 @@ tsmith123,pr,#1179,#790,good-first-issue,high,,,40k,stickymarch60@walletofsatosh
|
|||||||
SatsAllDay,pr,#1159,#510,medium-hard,,1,,450k,weareallsatoshi@getalby.com,2024-05-22
|
SatsAllDay,pr,#1159,#510,medium-hard,,1,,450k,weareallsatoshi@getalby.com,2024-05-22
|
||||||
Darth-Coin,issue,#1159,#510,medium-hard,,1,,45k,darthcoin@stacker.news,2024-05-22
|
Darth-Coin,issue,#1159,#510,medium-hard,,1,,45k,darthcoin@stacker.news,2024-05-22
|
||||||
OneOneSeven117,issue,#1187,#1164,easy,,,,10k,OneOneSeven@stacker.news,2024-05-23
|
OneOneSeven117,issue,#1187,#1164,easy,,,,10k,OneOneSeven@stacker.news,2024-05-23
|
||||||
|
tsmith123,pr,#1191,#134,medium,,,required small fix,225k,stickymarch60@walletofsatoshi.com,2024-05-28
|
||||||
|
benalleng,helpfulness,#1191,#134,medium,,,did most of this before,100k,benalleng@mutiny.plus,2024-05-28
|
||||||
|
cointastical,issue,#1191,#134,medium,,,,22k,cointastical@stacker.news,2024-05-28
|
||||||
|
kravhen,pr,#1198,#1180,good-first-issue,,,required linting,18k,???,???
|
||||||
|
OneOneSeven117,issue,#1198,#1180,good-first-issue,,,required linting,2k,OneOneSeven@stacker.news,2024-05-28
|
||||||
|
|
@ -107,7 +107,8 @@ export function BountyForm ({
|
|||||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||||
}}
|
}}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
invoiceable={{ requireSession: true }}
|
requireSession
|
||||||
|
prepaid
|
||||||
onSubmit={
|
onSubmit={
|
||||||
handleSubmit ||
|
handleSubmit ||
|
||||||
onSubmit
|
onSubmit
|
||||||
|
187
components/client-notifications.js
Normal file
187
components/client-notifications.js
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import { useApolloClient } from '@apollo/client'
|
||||||
|
import { useMe } from './me'
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { datePivot, timeSince } from '@/lib/time'
|
||||||
|
import { ANON_USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
|
||||||
|
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
|
||||||
|
import Item from './item'
|
||||||
|
import { RootProvider } from './root'
|
||||||
|
import Comment from './comment'
|
||||||
|
|
||||||
|
const toType = t => ({ ERROR: `${t}_ERROR`, PENDING: `${t}_PENDING` })
|
||||||
|
|
||||||
|
export const Types = {
|
||||||
|
Zap: toType('ZAP'),
|
||||||
|
Reply: toType('REPLY'),
|
||||||
|
Bounty: toType('BOUNTY'),
|
||||||
|
PollVote: toType('POLL_VOTE')
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClientNotificationContext = createContext({ notifications: [], notify: () => {}, unnotify: () => {} })
|
||||||
|
|
||||||
|
export function ClientNotificationProvider ({ children }) {
|
||||||
|
const [notifications, setNotifications] = useState([])
|
||||||
|
const client = useApolloClient()
|
||||||
|
const me = useMe()
|
||||||
|
// anons don't have access to /notifications
|
||||||
|
// but we'll store client notifications anyway for simplicity's sake
|
||||||
|
const storageKey = `client-notifications:${me?.id || ANON_USER_ID}`
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loaded = loadNotifications(storageKey, client)
|
||||||
|
setNotifications(loaded)
|
||||||
|
}, [storageKey])
|
||||||
|
|
||||||
|
const notify = useCallback((type, props) => {
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const sortTime = new Date()
|
||||||
|
const expiresAt = +datePivot(sortTime, { milliseconds: JIT_INVOICE_TIMEOUT_MS })
|
||||||
|
const isError = type.endsWith('ERROR')
|
||||||
|
const n = { __typename: type, id, sortTime: +sortTime, pending: !isError, expiresAt, ...props }
|
||||||
|
|
||||||
|
setNotifications(notifications => [n, ...notifications])
|
||||||
|
saveNotification(storageKey, n)
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
client?.writeQuery({
|
||||||
|
query: HAS_NOTIFICATIONS,
|
||||||
|
data: {
|
||||||
|
hasNewNotes: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}, [storageKey, client])
|
||||||
|
|
||||||
|
const unnotify = useCallback((id) => {
|
||||||
|
setNotifications(notifications => notifications.filter(n => n.id !== id))
|
||||||
|
removeNotification(storageKey, id)
|
||||||
|
}, [storageKey])
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ notifications, notify, unnotify }), [notifications, notify, unnotify])
|
||||||
|
return (
|
||||||
|
<ClientNotificationContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ClientNotificationContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientNotifyProvider ({ children, additionalProps }) {
|
||||||
|
const ctx = useClientNotifications()
|
||||||
|
|
||||||
|
const notify = useCallback((type, props) => {
|
||||||
|
return ctx.notify(type, { ...props, ...additionalProps })
|
||||||
|
}, [ctx.notify])
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ ...ctx, notify }), [ctx, notify])
|
||||||
|
return (
|
||||||
|
<ClientNotificationContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ClientNotificationContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClientNotifications () {
|
||||||
|
return useContext(ClientNotificationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientNotification ({ n, message }) {
|
||||||
|
if (n.pending) {
|
||||||
|
const expired = n.expiresAt < +new Date()
|
||||||
|
if (!expired) return null
|
||||||
|
n.reason = 'invoice expired'
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove payment hashes due to x-overflow
|
||||||
|
n.reason = n.reason.replace(/(: )?[a-f0-9]{64}/, '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='ms-2'>
|
||||||
|
<small className='fw-bold text-danger'>
|
||||||
|
{n.reason ? `${message}: ${n.reason}` : message}
|
||||||
|
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||||
|
</small>
|
||||||
|
{!n.item
|
||||||
|
? null
|
||||||
|
: n.item.title
|
||||||
|
? <Item item={n.item} />
|
||||||
|
: (
|
||||||
|
<div className='pb-2'>
|
||||||
|
<RootProvider root={n.item.root}>
|
||||||
|
<Comment item={n.item} noReply includeParent noComments clickToContext />
|
||||||
|
</RootProvider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientZap ({ n }) {
|
||||||
|
const message = `failed to zap ${n.sats || n.amount} sats`
|
||||||
|
return <ClientNotification n={n} message={message} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientReply ({ n }) {
|
||||||
|
const message = 'failed to submit reply'
|
||||||
|
return <ClientNotification n={n} message={message} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientBounty ({ n }) {
|
||||||
|
const message = 'failed to pay bounty'
|
||||||
|
return <ClientNotification n={n} message={message} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientPollVote ({ n }) {
|
||||||
|
const message = 'failed to submit poll vote'
|
||||||
|
return <ClientNotification n={n} message={message} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNotifications (storageKey, client) {
|
||||||
|
const stored = window.localStorage.getItem(storageKey)
|
||||||
|
if (!stored) return []
|
||||||
|
|
||||||
|
const filtered = JSON.parse(stored).filter(({ sortTime }) => {
|
||||||
|
// only keep notifications younger than 24 hours
|
||||||
|
return new Date(sortTime) >= datePivot(new Date(), { hours: -24 })
|
||||||
|
})
|
||||||
|
|
||||||
|
let hasNewNotes = false
|
||||||
|
const mapped = filtered.map((n) => {
|
||||||
|
if (!n.pending) return n
|
||||||
|
// anything that is still pending when we load the page was interrupted
|
||||||
|
// so we immediately mark it as failed instead of waiting until it expired
|
||||||
|
const type = n.__typename.replace('PENDING', 'ERROR')
|
||||||
|
const reason = 'payment was interrupted'
|
||||||
|
hasNewNotes = true
|
||||||
|
return { ...n, __typename: type, pending: false, reason }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasNewNotes) {
|
||||||
|
client?.writeQuery({
|
||||||
|
query: HAS_NOTIFICATIONS,
|
||||||
|
data: {
|
||||||
|
hasNewNotes: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify(mapped))
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveNotification (storageKey, n) {
|
||||||
|
const stored = window.localStorage.getItem(storageKey)
|
||||||
|
if (stored) {
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify([...JSON.parse(stored), n]))
|
||||||
|
} else {
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify([n]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNotification (storageKey, id) {
|
||||||
|
const stored = window.localStorage.getItem(storageKey)
|
||||||
|
if (stored) {
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify(JSON.parse(stored).filter(n => n.id !== id)))
|
||||||
|
}
|
||||||
|
}
|
@ -145,7 +145,7 @@ export default function Comment ({
|
|||||||
{item.outlawed && !me?.privates?.wildWestMode
|
{item.outlawed && !me?.privates?.wildWestMode
|
||||||
? <Skull className={styles.dontLike} width={24} height={24} />
|
? <Skull className={styles.dontLike} width={24} height={24} />
|
||||||
: item.meDontLikeSats > item.meSats
|
: item.meDontLikeSats > item.meSats
|
||||||
? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} />
|
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
||||||
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
|
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
|
||||||
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
||||||
<div className='d-flex align-items-center'>
|
<div className='d-flex align-items-center'>
|
||||||
|
@ -96,7 +96,7 @@ export function DiscussionForm ({
|
|||||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||||
}}
|
}}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
invoiceable
|
prepaid
|
||||||
onSubmit={handleSubmit || onSubmit}
|
onSubmit={handleSubmit || onSubmit}
|
||||||
storageKeyPrefix={storageKeyPrefix}
|
storageKeyPrefix={storageKeyPrefix}
|
||||||
>
|
>
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import Dropdown from 'react-bootstrap/Dropdown'
|
import Dropdown from 'react-bootstrap/Dropdown'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import ItemAct, { zapUndosThresholdReached } from './item-act'
|
import ItemAct from './item-act'
|
||||||
import AccordianItem from './accordian-item'
|
import AccordianItem from './accordian-item'
|
||||||
import Flag from '@/svgs/flag-fill.svg'
|
import Flag from '@/svgs/flag-fill.svg'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import getColor from '@/lib/rainbow'
|
import getColor from '@/lib/rainbow'
|
||||||
import { gql, useMutation } from '@apollo/client'
|
import { gql, useMutation } from '@apollo/client'
|
||||||
import { useMe } from './me'
|
|
||||||
|
|
||||||
export function DownZap ({ id, meDontLikeSats, ...props }) {
|
export function DownZap ({ item, ...props }) {
|
||||||
|
const { meDontLikeSats } = item
|
||||||
const style = useMemo(() => (meDontLikeSats
|
const style = useMemo(() => (meDontLikeSats
|
||||||
? {
|
? {
|
||||||
fill: getColor(meDontLikeSats),
|
fill: getColor(meDontLikeSats),
|
||||||
@ -17,14 +17,13 @@ export function DownZap ({ id, meDontLikeSats, ...props }) {
|
|||||||
}
|
}
|
||||||
: undefined), [meDontLikeSats])
|
: undefined), [meDontLikeSats])
|
||||||
return (
|
return (
|
||||||
<DownZapper id={id} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
|
<DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DownZapper ({ id, As, children }) {
|
function DownZapper ({ item, As, children }) {
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
const me = useMe()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<As
|
<As
|
||||||
@ -32,12 +31,7 @@ function DownZapper ({ id, As, children }) {
|
|||||||
try {
|
try {
|
||||||
showModal(onClose =>
|
showModal(onClose =>
|
||||||
<ItemAct
|
<ItemAct
|
||||||
onClose={(amount) => {
|
onClose={onClose} item={item} down
|
||||||
onClose()
|
|
||||||
// undo prompt was toasted before closing modal if zap undos are enabled
|
|
||||||
// so an additional success toast would be confusing
|
|
||||||
if (!zapUndosThresholdReached(me, amount)) toaster.success('item downzapped')
|
|
||||||
}} itemId={id} down
|
|
||||||
>
|
>
|
||||||
<AccordianItem
|
<AccordianItem
|
||||||
header='what is a downzap?' body={
|
header='what is a downzap?' body={
|
||||||
@ -59,11 +53,11 @@ function DownZapper ({ id, As, children }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DontLikeThisDropdownItem ({ id }) {
|
export default function DontLikeThisDropdownItem ({ item }) {
|
||||||
return (
|
return (
|
||||||
<DownZapper
|
<DownZapper
|
||||||
As={Dropdown.Item}
|
As={Dropdown.Item}
|
||||||
id={id}
|
item={item}
|
||||||
>
|
>
|
||||||
<span className='text-danger'>downzap</span>
|
<span className='text-danger'>downzap</span>
|
||||||
</DownZapper>
|
</DownZapper>
|
||||||
|
@ -64,6 +64,7 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
|
|||||||
export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) {
|
export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) {
|
||||||
const [lineItems, setLineItems] = useState({})
|
const [lineItems, setLineItems] = useState({})
|
||||||
const [disabled, setDisabled] = useState(false)
|
const [disabled, setDisabled] = useState(false)
|
||||||
|
const me = useMe()
|
||||||
|
|
||||||
const remoteLineItems = useRemoteLineItems()
|
const remoteLineItems = useRemoteLineItems()
|
||||||
|
|
||||||
@ -76,14 +77,18 @@ export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = ()
|
|||||||
|
|
||||||
const value = useMemo(() => {
|
const value = useMemo(() => {
|
||||||
const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems }
|
const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems }
|
||||||
|
const total = Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0)
|
||||||
|
// freebies: there's only a base cost and we don't have enough sats
|
||||||
|
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total
|
||||||
return {
|
return {
|
||||||
lines,
|
lines,
|
||||||
merge: mergeLineItems,
|
merge: mergeLineItems,
|
||||||
total: Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0),
|
total,
|
||||||
disabled,
|
disabled,
|
||||||
setDisabled
|
setDisabled,
|
||||||
|
free
|
||||||
}
|
}
|
||||||
}, [baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled])
|
}, [me?.privates?.sats, baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeeButtonContext.Provider value={value}>
|
<FeeButtonContext.Provider value={value}>
|
||||||
@ -111,9 +116,7 @@ function FreebieDialog () {
|
|||||||
|
|
||||||
export default function FeeButton ({ ChildButton = SubmitButton, variant, text, disabled }) {
|
export default function FeeButton ({ ChildButton = SubmitButton, variant, text, disabled }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const { lines, total, disabled: ctxDisabled } = useFeeButton()
|
const { lines, total, disabled: ctxDisabled, free } = useFeeButton()
|
||||||
// freebies: there's only a base cost and we don't have enough sats
|
|
||||||
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total
|
|
||||||
const feeText = free
|
const feeText = free
|
||||||
? 'free'
|
? 'free'
|
||||||
: total > 1
|
: total > 1
|
||||||
|
@ -18,7 +18,6 @@ import { gql, useLazyQuery } from '@apollo/client'
|
|||||||
import { USER_SUGGESTIONS } from '@/fragments/users'
|
import { USER_SUGGESTIONS } from '@/fragments/users'
|
||||||
import TextareaAutosize from 'react-textarea-autosize'
|
import TextareaAutosize from 'react-textarea-autosize'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import { useInvoiceable } from './invoice'
|
|
||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import textAreaCaret from 'textarea-caret'
|
import textAreaCaret from 'textarea-caret'
|
||||||
import ReactDatePicker from 'react-datepicker'
|
import ReactDatePicker from 'react-datepicker'
|
||||||
@ -32,6 +31,18 @@ import Thumb from '@/svgs/thumb-up-fill.svg'
|
|||||||
import Eye from '@/svgs/eye-fill.svg'
|
import Eye from '@/svgs/eye-fill.svg'
|
||||||
import EyeClose from '@/svgs/eye-close-line.svg'
|
import EyeClose from '@/svgs/eye-close-line.svg'
|
||||||
import Info from './info'
|
import Info from './info'
|
||||||
|
import { InvoiceCanceledError, usePayment } from './payment'
|
||||||
|
import { useMe } from './me'
|
||||||
|
import { optimisticUpdate } from '@/lib/apollo'
|
||||||
|
import { useClientNotifications } from './client-notifications'
|
||||||
|
import { ActCanceledError } from './item-act'
|
||||||
|
|
||||||
|
export class SessionRequiredError extends Error {
|
||||||
|
constructor () {
|
||||||
|
super('session required')
|
||||||
|
this.name = 'SessionRequiredError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function SubmitButton ({
|
export function SubmitButton ({
|
||||||
children, variant, value, onClick, disabled, nonDisabledText, ...props
|
children, variant, value, onClick, disabled, nonDisabledText, ...props
|
||||||
@ -793,11 +804,16 @@ const StorageKeyPrefixContext = createContext()
|
|||||||
|
|
||||||
export function Form ({
|
export function Form ({
|
||||||
initial, schema, onSubmit, children, initialError, validateImmediately,
|
initial, schema, onSubmit, children, initialError, validateImmediately,
|
||||||
storageKeyPrefix, validateOnChange = true, invoiceable, innerRef, ...props
|
storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef,
|
||||||
|
optimisticUpdate: optimisticUpdateArgs, clientNotification, signal, ...props
|
||||||
}) {
|
}) {
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const initialErrorToasted = useRef(false)
|
const initialErrorToasted = useRef(false)
|
||||||
const feeButton = useFeeButton()
|
const feeButton = useFeeButton()
|
||||||
|
const payment = usePayment()
|
||||||
|
const me = useMe()
|
||||||
|
const { notify, unnotify } = useClientNotifications()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialError && !initialErrorToasted.current) {
|
if (initialError && !initialErrorToasted.current) {
|
||||||
toaster.danger('form error: ' + initialError.message || initialError.toString?.())
|
toaster.danger('form error: ' + initialError.message || initialError.toString?.())
|
||||||
@ -820,37 +836,57 @@ export function Form ({
|
|||||||
})
|
})
|
||||||
}, [storageKeyPrefix])
|
}, [storageKeyPrefix])
|
||||||
|
|
||||||
// if `invoiceable` is set,
|
const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => {
|
||||||
// support for payment per invoice if they are lurking or don't have enough balance
|
const variables = { amount, ...values }
|
||||||
// is added to submit handlers.
|
let revert, cancel, nid
|
||||||
// submit handlers need to accept { satsReceived, hash, hmac } in their first argument
|
|
||||||
// and use them as variables in their GraphQL mutation
|
|
||||||
if (invoiceable && onSubmit) {
|
|
||||||
const options = typeof invoiceable === 'object' ? invoiceable : undefined
|
|
||||||
onSubmit = useInvoiceable(onSubmit, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSubmitInner = useCallback(async (values, ...args) => {
|
|
||||||
try {
|
try {
|
||||||
if (onSubmit) {
|
if (onSubmit) {
|
||||||
// extract cost from formik fields
|
if (requireSession && !me) {
|
||||||
// (cost may also be set in a formik field named 'amount')
|
throw new SessionRequiredError()
|
||||||
const cost = feeButton?.total || values?.amount
|
|
||||||
if (cost) {
|
|
||||||
values.cost = cost
|
|
||||||
}
|
}
|
||||||
await onSubmit(values, ...args)
|
|
||||||
|
if (optimisticUpdateArgs) {
|
||||||
|
revert = optimisticUpdate(optimisticUpdateArgs(variables))
|
||||||
|
}
|
||||||
|
|
||||||
|
await signal?.pause({ me, amount })
|
||||||
|
|
||||||
|
if (me && clientNotification) {
|
||||||
|
nid = notify(clientNotification.PENDING, variables)
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash, hmac
|
||||||
|
if (prepaid) {
|
||||||
|
[{ hash, hmac }, cancel] = await payment.request(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubmit({ hash, hmac, ...variables }, ...args)
|
||||||
|
|
||||||
if (!storageKeyPrefix) return
|
if (!storageKeyPrefix) return
|
||||||
clearLocalStorage(values)
|
clearLocalStorage(values)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err.message || err.toString?.()
|
revert?.()
|
||||||
// ignore errors from JIT invoices or payments from attached wallets
|
|
||||||
// that mean that submit failed because user aborted the payment
|
if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) {
|
||||||
if (msg === 'modal closed' || msg === 'invoice canceled') return
|
return
|
||||||
toaster.danger('submit error: ' + msg)
|
}
|
||||||
|
|
||||||
|
const reason = err.message || err.toString?.()
|
||||||
|
if (me && clientNotification) {
|
||||||
|
notify(clientNotification.ERROR, { ...variables, reason })
|
||||||
|
} else {
|
||||||
|
toaster.danger('submit error: ' + reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel?.()
|
||||||
|
} finally {
|
||||||
|
// if we reach this line, the submit either failed or was successful so we can remove the pending notification.
|
||||||
|
// if we don't reach this line, the page was probably reloaded and we can use the pending notification
|
||||||
|
// stored in localStorage to handle this case.
|
||||||
|
if (nid) unnotify(nid)
|
||||||
}
|
}
|
||||||
}, [onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix])
|
}, [me, onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix, payment, signal])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
|
@ -1,23 +1,35 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useApolloClient, useMutation, useQuery } from '@apollo/client'
|
|
||||||
import { Button } from 'react-bootstrap'
|
|
||||||
import { gql } from 'graphql-tag'
|
|
||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import AccordianItem from './accordian-item'
|
import AccordianItem from './accordian-item'
|
||||||
import Qr, { QrSkeleton } from './qr'
|
import Qr from './qr'
|
||||||
import { INVOICE } from '@/fragments/wallet'
|
|
||||||
import InvoiceStatus from './invoice-status'
|
|
||||||
import { useMe } from './me'
|
|
||||||
import { useShowModal } from './modal'
|
|
||||||
import Countdown from './countdown'
|
import Countdown from './countdown'
|
||||||
import PayerData from './payer-data'
|
import PayerData from './payer-data'
|
||||||
import Bolt11Info from './bolt11-info'
|
import Bolt11Info from './bolt11-info'
|
||||||
import { useWebLN } from './webln'
|
import { useQuery } from '@apollo/client'
|
||||||
import { FAST_POLL_INTERVAL } from '@/lib/constants'
|
import { INVOICE } from '@/fragments/wallet'
|
||||||
|
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||||
|
import { WebLnNotEnabledError } from './payment'
|
||||||
|
|
||||||
export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }) {
|
export default function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn, webLnError, poll }) {
|
||||||
const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date())
|
const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date())
|
||||||
|
|
||||||
|
const { data, error } = useQuery(INVOICE, SSR
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
pollInterval: FAST_POLL_INTERVAL,
|
||||||
|
variables: { id: invoice.id },
|
||||||
|
nextFetchPolicy: 'cache-and-network',
|
||||||
|
skip: !poll
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
invoice = data.invoice
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div>{error.toString()}</div>
|
||||||
|
}
|
||||||
|
|
||||||
// if webLn was not passed, use true by default
|
// if webLn was not passed, use true by default
|
||||||
if (webLn === undefined) webLn = true
|
if (webLn === undefined) webLn = true
|
||||||
|
|
||||||
@ -48,6 +60,11 @@ export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{webLnError && !(webLnError instanceof WebLnNotEnabledError) &&
|
||||||
|
<div className='text-center text-danger mb-3'>
|
||||||
|
Payment from attached wallet failed:
|
||||||
|
<div>{webLnError.toString()}</div>
|
||||||
|
</div>}
|
||||||
<Qr
|
<Qr
|
||||||
webLn={webLn} value={invoice.bolt11}
|
webLn={webLn} value={invoice.bolt11}
|
||||||
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
||||||
@ -105,289 +122,3 @@ export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const JITInvoice = ({ invoice: { id, hash, hmac, expiresAt }, onPayment, onCancel, onRetry }) => {
|
|
||||||
const { data, loading, error } = useQuery(INVOICE, {
|
|
||||||
pollInterval: FAST_POLL_INTERVAL,
|
|
||||||
variables: { id }
|
|
||||||
})
|
|
||||||
const [retryError, setRetryError] = useState(0)
|
|
||||||
if (error) {
|
|
||||||
if (error.message?.includes('invoice not found')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return <div>error</div>
|
|
||||||
}
|
|
||||||
if (!data || loading) {
|
|
||||||
return <QrSkeleton description status='loading' />
|
|
||||||
}
|
|
||||||
|
|
||||||
const retry = !!onRetry
|
|
||||||
let errorStatus = 'Something went wrong trying to perform the action after payment.'
|
|
||||||
if (retryError > 0) {
|
|
||||||
errorStatus = 'Something still went wrong.\nYou can retry or cancel the invoice to return your funds.'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Invoice invoice={data.invoice} modal onPayment={onPayment} successVerb='received' webLn={false} />
|
|
||||||
{retry
|
|
||||||
? (
|
|
||||||
<>
|
|
||||||
<div className='my-3'>
|
|
||||||
<InvoiceStatus variant='failed' status={errorStatus} />
|
|
||||||
</div>
|
|
||||||
<div className='d-flex flex-row mt-3 justify-content-center'>
|
|
||||||
<Button
|
|
||||||
className='mx-1' variant='info' onClick={async () => {
|
|
||||||
try {
|
|
||||||
await onRetry()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('retry error:', err)
|
|
||||||
setRetryError(retryError => retryError + 1)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>Retry
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className='mx-1'
|
|
||||||
variant='danger'
|
|
||||||
onClick={onCancel}
|
|
||||||
>Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
: null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions = {
|
|
||||||
requireSession: false,
|
|
||||||
forceInvoice: false
|
|
||||||
}
|
|
||||||
export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
|
||||||
const me = useMe()
|
|
||||||
const [createInvoice] = useMutation(gql`
|
|
||||||
mutation createInvoice($amount: Int!) {
|
|
||||||
createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) {
|
|
||||||
id
|
|
||||||
bolt11
|
|
||||||
hash
|
|
||||||
hmac
|
|
||||||
expiresAt
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
const [cancelInvoice] = useMutation(gql`
|
|
||||||
mutation cancelInvoice($hash: String!, $hmac: String!) {
|
|
||||||
cancelInvoice(hash: $hash, hmac: $hmac) {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
|
|
||||||
const showModal = useShowModal()
|
|
||||||
const provider = useWebLN()
|
|
||||||
const client = useApolloClient()
|
|
||||||
const pollInvoice = (id) => client.query({ query: INVOICE, fetchPolicy: 'no-cache', variables: { id } })
|
|
||||||
|
|
||||||
const onSubmitWrapper = useCallback(async (
|
|
||||||
{ cost, ...formValues },
|
|
||||||
{ variables, optimisticResponse, update, flowId, ...submitArgs }) => {
|
|
||||||
// some actions require a session
|
|
||||||
if (!me && options.requireSession) {
|
|
||||||
throw new Error('you must be logged in')
|
|
||||||
}
|
|
||||||
|
|
||||||
// id for toast flows
|
|
||||||
if (!flowId) flowId = (+new Date()).toString(16)
|
|
||||||
|
|
||||||
// educated guesses where action might pass in the invoice amount
|
|
||||||
// (field 'cost' has highest precedence)
|
|
||||||
cost ??= formValues.amount
|
|
||||||
|
|
||||||
// attempt action for the first time
|
|
||||||
if (!cost || (me && !options.forceInvoice)) {
|
|
||||||
try {
|
|
||||||
const insufficientFunds = me?.privates.sats < cost
|
|
||||||
return await onSubmit(formValues,
|
|
||||||
{ ...submitArgs, flowId, variables, optimisticsResponse: insufficientFunds ? null : optimisticResponse, update })
|
|
||||||
} catch (error) {
|
|
||||||
if (!payOrLoginError(error) || !cost) {
|
|
||||||
// can't handle error here - bail
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// initial attempt of action failed. we will create an invoice, pay and retry now.
|
|
||||||
const { data, error } = await createInvoice({ variables: { amount: cost } })
|
|
||||||
if (error) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
const inv = data.createInvoice
|
|
||||||
|
|
||||||
// If this is a zap, we need to manually be optimistic to have a consistent
|
|
||||||
// UX across custodial and WebLN zaps since WebLN zaps don't call GraphQL
|
|
||||||
// mutations which implement optimistic responses natively.
|
|
||||||
// Therefore, we check if this is a zap and then wrap the WebLN payment logic
|
|
||||||
// with manual cache update calls.
|
|
||||||
const itemId = optimisticResponse?.act?.id
|
|
||||||
const isZap = !!itemId
|
|
||||||
let _update
|
|
||||||
if (isZap && update) {
|
|
||||||
_update = () => {
|
|
||||||
const fragment = {
|
|
||||||
id: `Item:${itemId}`,
|
|
||||||
fragment: gql`
|
|
||||||
fragment ItemMeSatsInvoice on Item {
|
|
||||||
sats
|
|
||||||
meSats
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
const item = client.cache.readFragment(fragment)
|
|
||||||
update(client.cache, { data: optimisticResponse })
|
|
||||||
// undo function
|
|
||||||
return () => client.cache.writeFragment({ ...fragment, data: item })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait until invoice is paid or modal is closed
|
|
||||||
const { modalOnClose, webLn, gqlCacheUpdateUndo } = await waitForPayment({
|
|
||||||
invoice: inv,
|
|
||||||
showModal,
|
|
||||||
provider,
|
|
||||||
pollInvoice,
|
|
||||||
gqlCacheUpdate: _update,
|
|
||||||
flowId
|
|
||||||
})
|
|
||||||
|
|
||||||
const retry = () => onSubmit(
|
|
||||||
{ hash: inv.hash, hmac: inv.hmac, expiresAt: inv.expiresAt, ...formValues },
|
|
||||||
// unset update function since we already ran an cache update if we paid using WebLN
|
|
||||||
// also unset update function if null was explicitly passed in
|
|
||||||
{ ...submitArgs, variables, update: webLn ? null : undefined })
|
|
||||||
// first retry
|
|
||||||
try {
|
|
||||||
const ret = await retry()
|
|
||||||
modalOnClose?.()
|
|
||||||
return ret
|
|
||||||
} catch (error) {
|
|
||||||
gqlCacheUpdateUndo?.()
|
|
||||||
console.error('retry error:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// retry until success or cancel
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const cancelAndReject = async () => {
|
|
||||||
await cancelInvoice({ variables: { hash: inv.hash, hmac: inv.hmac } })
|
|
||||||
reject(new Error('invoice canceled'))
|
|
||||||
}
|
|
||||||
showModal(onClose => {
|
|
||||||
return (
|
|
||||||
<JITInvoice
|
|
||||||
invoice={inv}
|
|
||||||
onCancel={async () => {
|
|
||||||
await cancelAndReject()
|
|
||||||
onClose()
|
|
||||||
}}
|
|
||||||
onRetry={async () => {
|
|
||||||
resolve(await retry())
|
|
||||||
onClose()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}, { keepOpen: true, onClose: cancelAndReject })
|
|
||||||
})
|
|
||||||
}, [onSubmit, provider, createInvoice, !!me])
|
|
||||||
|
|
||||||
return onSubmitWrapper
|
|
||||||
}
|
|
||||||
|
|
||||||
const INVOICE_CANCELED_ERROR = 'invoice canceled'
|
|
||||||
const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, gqlCacheUpdate, flowId }) => {
|
|
||||||
if (provider) {
|
|
||||||
try {
|
|
||||||
return await waitForWebLNPayment({ provider, invoice, pollInvoice, gqlCacheUpdate, flowId })
|
|
||||||
} catch (err) {
|
|
||||||
// check for errors which mean that QR code will also fail
|
|
||||||
if (err.message === INVOICE_CANCELED_ERROR) {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// QR code as fallback
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
showModal(onClose => {
|
|
||||||
return (
|
|
||||||
<JITInvoice
|
|
||||||
invoice={invoice}
|
|
||||||
onPayment={() => resolve({ modalOnClose: onClose })}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}, { keepOpen: true, onClose: reject })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, gqlCacheUpdate, flowId }) => {
|
|
||||||
let undoUpdate
|
|
||||||
try {
|
|
||||||
// try WebLN provider first
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
// be optimistic and pretend zap was already successful for consistent zapping UX
|
|
||||||
undoUpdate = gqlCacheUpdate?.()
|
|
||||||
// can't use await here since we might be paying JIT invoices
|
|
||||||
// and sendPaymentAsync is not supported yet.
|
|
||||||
// see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync
|
|
||||||
provider.sendPayment({ ...invoice, flowId })
|
|
||||||
// WebLN payment will never resolve here for JIT invoices
|
|
||||||
// since they only get resolved after settlement which can't happen here
|
|
||||||
.then(() => resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate }))
|
|
||||||
.catch(err => {
|
|
||||||
clearInterval(interval)
|
|
||||||
reject(err)
|
|
||||||
})
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const { data, error } = await pollInvoice(invoice.id)
|
|
||||||
if (error) {
|
|
||||||
clearInterval(interval)
|
|
||||||
return reject(error)
|
|
||||||
}
|
|
||||||
const { invoice: inv } = data
|
|
||||||
if (inv.isHeld && inv.satsReceived) {
|
|
||||||
clearInterval(interval)
|
|
||||||
resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate })
|
|
||||||
}
|
|
||||||
if (inv.cancelled) {
|
|
||||||
clearInterval(interval)
|
|
||||||
reject(new Error(INVOICE_CANCELED_ERROR))
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
clearInterval(interval)
|
|
||||||
reject(err)
|
|
||||||
}
|
|
||||||
}, FAST_POLL_INTERVAL)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
undoUpdate?.()
|
|
||||||
console.error('WebLN payment failed:', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useInvoiceModal = (onPayment, deps) => {
|
|
||||||
const onPaymentMemo = useCallback(onPayment, deps)
|
|
||||||
return useInvoiceable(onPaymentMemo, { replaceModal: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
export const payOrLoginError = (error) => {
|
|
||||||
const matches = ['insufficient funds', 'you must be logged in or pay']
|
|
||||||
if (Array.isArray(error)) {
|
|
||||||
return error.some(({ message }) => matches.some(m => message.includes(m)))
|
|
||||||
}
|
|
||||||
return matches.some(m => error.toString().includes(m))
|
|
||||||
}
|
|
||||||
|
@ -5,13 +5,16 @@ import { Form, Input, SubmitButton } from './form'
|
|||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import UpBolt from '@/svgs/bolt.svg'
|
import UpBolt from '@/svgs/bolt.svg'
|
||||||
import { amountSchema } from '@/lib/validate'
|
import { amountSchema } from '@/lib/validate'
|
||||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
import { gql, useMutation } from '@apollo/client'
|
||||||
import { payOrLoginError, useInvoiceModal } from './invoice'
|
import { useToast } from './toast'
|
||||||
import { TOAST_DEFAULT_DELAY_MS, useToast, withToastFlow } from './toast'
|
|
||||||
import { useLightning } from './lightning'
|
import { useLightning } from './lightning'
|
||||||
import { nextTip } from './upvote'
|
import { nextTip } from './upvote'
|
||||||
|
import { InvoiceCanceledError, usePayment } from './payment'
|
||||||
|
import { optimisticUpdate } from '@/lib/apollo'
|
||||||
|
import { Types as ClientNotification, ClientNotifyProvider, useClientNotifications } from './client-notifications'
|
||||||
|
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
|
||||||
|
|
||||||
const defaultTips = [100, 1000, 10000, 100000]
|
const defaultTips = [100, 1000, 10_000, 100_000]
|
||||||
|
|
||||||
const Tips = ({ setOValue }) => {
|
const Tips = ({ setOValue }) => {
|
||||||
const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b)
|
const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b)
|
||||||
@ -41,222 +44,149 @@ const addCustomTip = (amount) => {
|
|||||||
window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
|
window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const zapUndosThresholdReached = (me, amount) => {
|
const setItemMeAnonSats = ({ id, amount }) => {
|
||||||
if (!me) return false
|
const storageKey = `TIP-item:${id}`
|
||||||
const enabled = me.privates.zapUndos !== null
|
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
|
||||||
return enabled ? amount >= me.privates.zapUndos : false
|
window.localStorage.setItem(storageKey, existingAmount + amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ItemAct ({ onClose, itemId, down, children }) {
|
export const actUpdate = ({ me, onUpdate }) => (cache, args) => {
|
||||||
|
const { data: { act: { id, sats, path, act } } } = args
|
||||||
|
|
||||||
|
cache.modify({
|
||||||
|
id: `Item:${id}`,
|
||||||
|
fields: {
|
||||||
|
sats (existingSats = 0) {
|
||||||
|
if (act === 'TIP') {
|
||||||
|
return existingSats + sats
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingSats
|
||||||
|
},
|
||||||
|
meSats: (existingSats = 0) => {
|
||||||
|
if (act === 'TIP') {
|
||||||
|
return existingSats + sats
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingSats
|
||||||
|
},
|
||||||
|
meDontLikeSats: me
|
||||||
|
? (existingSats = 0) => {
|
||||||
|
if (act === 'DONT_LIKE_THIS') {
|
||||||
|
return existingSats + sats
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingSats
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (act === 'TIP') {
|
||||||
|
// update all ancestors
|
||||||
|
path.split('.').forEach(aId => {
|
||||||
|
if (Number(aId) === Number(id)) return
|
||||||
|
cache.modify({
|
||||||
|
id: `Item:${aId}`,
|
||||||
|
fields: {
|
||||||
|
commentSats (existingCommentSats = 0) {
|
||||||
|
return existingCommentSats + sats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate?.(cache, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ItemAct ({ onClose, item, down, children, abortSignal }) {
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const [oValue, setOValue] = useState()
|
const [oValue, setOValue] = useState()
|
||||||
const strike = useLightning()
|
const strike = useLightning()
|
||||||
const toaster = useToast()
|
|
||||||
const client = useApolloClient()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
}, [onClose, itemId])
|
}, [onClose, item.id])
|
||||||
|
|
||||||
const [act, actUpdate] = useAct()
|
const act = useAct()
|
||||||
|
|
||||||
const onSubmit = useCallback(async ({ amount, hash, hmac }, { update, keepOpen }) => {
|
const onSubmit = useCallback(async ({ amount, hash, hmac }) => {
|
||||||
if (!me) {
|
|
||||||
const storageKey = `TIP-item:${itemId}`
|
|
||||||
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
|
|
||||||
window.localStorage.setItem(storageKey, existingAmount + amount)
|
|
||||||
}
|
|
||||||
await act({
|
await act({
|
||||||
variables: {
|
variables: {
|
||||||
id: itemId,
|
id: item.id,
|
||||||
sats: Number(amount),
|
sats: Number(amount),
|
||||||
act: down ? 'DONT_LIKE_THIS' : 'TIP',
|
act: down ? 'DONT_LIKE_THIS' : 'TIP',
|
||||||
hash,
|
hash,
|
||||||
hmac
|
hmac
|
||||||
},
|
}
|
||||||
update
|
|
||||||
})
|
})
|
||||||
// only strike when zap undos not enabled
|
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
||||||
// due to optimistic UX on zap undos
|
|
||||||
if (!zapUndosThresholdReached(me, Number(amount))) await strike()
|
|
||||||
addCustomTip(Number(amount))
|
addCustomTip(Number(amount))
|
||||||
if (!keepOpen) onClose(Number(amount))
|
}, [me, act, down, item.id, strike])
|
||||||
}, [me, act, down, itemId, strike])
|
|
||||||
|
|
||||||
const onSubmitWithUndos = withToastFlow(toaster)(
|
const optimisticUpdate = useCallback(({ amount }) => {
|
||||||
(values, args) => {
|
const variables = {
|
||||||
const { flowId } = args
|
id: item.id,
|
||||||
let canceled
|
sats: Number(amount),
|
||||||
const sats = values.amount
|
act: down ? 'DONT_LIKE_THIS' : 'TIP'
|
||||||
const insufficientFunds = me?.privates?.sats < sats
|
|
||||||
const invoiceAttached = values.hash && values.hmac
|
|
||||||
if (insufficientFunds && !invoiceAttached) throw new Error('insufficient funds')
|
|
||||||
// payments from external wallets already have their own flow
|
|
||||||
// and we don't want to show undo toasts for them
|
|
||||||
const skipToastFlow = invoiceAttached
|
|
||||||
// update function for optimistic UX
|
|
||||||
const update = () => {
|
|
||||||
const fragment = {
|
|
||||||
id: `Item:${itemId}`,
|
|
||||||
fragment: gql`
|
|
||||||
fragment ItemMeSatsSubmit on Item {
|
|
||||||
path
|
|
||||||
sats
|
|
||||||
meSats
|
|
||||||
meDontLikeSats
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
const item = client.cache.readFragment(fragment)
|
|
||||||
const optimisticResponse = {
|
|
||||||
act: {
|
|
||||||
id: itemId, sats, path: item.path, act: down ? 'DONT_LIKE_THIS' : 'TIP'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
actUpdate(client.cache, { data: optimisticResponse })
|
|
||||||
return () => client.cache.writeFragment({ ...fragment, data: item })
|
|
||||||
}
|
|
||||||
let undoUpdate
|
|
||||||
return {
|
|
||||||
skipToastFlow,
|
|
||||||
flowId,
|
|
||||||
tag: itemId,
|
|
||||||
type: 'zap',
|
|
||||||
pendingMessage: `${down ? 'down' : ''}zapped ${sats} sats`,
|
|
||||||
onPending: async () => {
|
|
||||||
if (skipToastFlow) {
|
|
||||||
return onSubmit(values, { flowId, ...args, update: null })
|
|
||||||
}
|
|
||||||
await strike()
|
|
||||||
onClose(sats)
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
undoUpdate = update()
|
|
||||||
setTimeout(() => {
|
|
||||||
if (canceled) return resolve()
|
|
||||||
onSubmit(values, { flowId, ...args, update: null, keepOpen: true })
|
|
||||||
.then(resolve)
|
|
||||||
.catch((err) => {
|
|
||||||
undoUpdate()
|
|
||||||
reject(err)
|
|
||||||
})
|
|
||||||
}, TOAST_DEFAULT_DELAY_MS)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onUndo: () => {
|
|
||||||
canceled = true
|
|
||||||
undoUpdate?.()
|
|
||||||
},
|
|
||||||
hideSuccess: true,
|
|
||||||
hideError: true,
|
|
||||||
timeout: TOAST_DEFAULT_DELAY_MS
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
const optimisticResponse = { act: { ...variables, path: item.path } }
|
||||||
|
strike()
|
||||||
|
onClose()
|
||||||
|
return { mutation: ACT_MUTATION, variables, optimisticResponse, update: actUpdate({ me }) }
|
||||||
|
}, [item.id, down, !!me, strike])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<ClientNotifyProvider additionalProps={{ itemId: item.id }}>
|
||||||
initial={{
|
<Form
|
||||||
amount: me?.privates?.tipDefault || defaultTips[0],
|
initial={{
|
||||||
default: false
|
amount: me?.privates?.tipDefault || defaultTips[0],
|
||||||
}}
|
default: false
|
||||||
schema={amountSchema}
|
}}
|
||||||
invoiceable
|
schema={amountSchema}
|
||||||
onSubmit={(values, ...args) => {
|
prepaid
|
||||||
if (zapUndosThresholdReached(me, values.amount)) {
|
optimisticUpdate={optimisticUpdate}
|
||||||
return onSubmitWithUndos(values, ...args)
|
onSubmit={onSubmit}
|
||||||
}
|
clientNotification={ClientNotification.Zap}
|
||||||
return onSubmit(values, ...args)
|
signal={abortSignal}
|
||||||
}}
|
>
|
||||||
>
|
<Input
|
||||||
<Input
|
label='amount'
|
||||||
label='amount'
|
name='amount'
|
||||||
name='amount'
|
type='number'
|
||||||
type='number'
|
innerRef={inputRef}
|
||||||
innerRef={inputRef}
|
overrideValue={oValue}
|
||||||
overrideValue={oValue}
|
required
|
||||||
required
|
autoFocus
|
||||||
autoFocus
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
/>
|
||||||
/>
|
<div>
|
||||||
<div>
|
<Tips setOValue={setOValue} />
|
||||||
<Tips setOValue={setOValue} />
|
</div>
|
||||||
</div>
|
{children}
|
||||||
{children}
|
<div className='d-flex mt-3'>
|
||||||
<div className='d-flex mt-3'>
|
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
|
||||||
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
|
</div>
|
||||||
</div>
|
</Form>
|
||||||
</Form>
|
</ClientNotifyProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAct ({ onUpdate } = {}) {
|
export const ACT_MUTATION = gql`
|
||||||
const me = useMe()
|
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) {
|
||||||
|
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
|
||||||
const update = useCallback((cache, args) => {
|
id
|
||||||
const { data: { act: { id, sats, path, act } } } = args
|
sats
|
||||||
|
path
|
||||||
cache.modify({
|
act
|
||||||
id: `Item:${id}`,
|
|
||||||
fields: {
|
|
||||||
sats (existingSats = 0) {
|
|
||||||
if (act === 'TIP') {
|
|
||||||
return existingSats + sats
|
|
||||||
}
|
|
||||||
|
|
||||||
return existingSats
|
|
||||||
},
|
|
||||||
meSats: me
|
|
||||||
? (existingSats = 0) => {
|
|
||||||
if (act === 'TIP') {
|
|
||||||
return existingSats + sats
|
|
||||||
}
|
|
||||||
|
|
||||||
return existingSats
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
meDontLikeSats: me
|
|
||||||
? (existingSats = 0) => {
|
|
||||||
if (act === 'DONT_LIKE_THIS') {
|
|
||||||
return existingSats + sats
|
|
||||||
}
|
|
||||||
|
|
||||||
return existingSats
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (act === 'TIP') {
|
|
||||||
// update all ancestors
|
|
||||||
path.split('.').forEach(aId => {
|
|
||||||
if (Number(aId) === Number(id)) return
|
|
||||||
cache.modify({
|
|
||||||
id: `Item:${aId}`,
|
|
||||||
fields: {
|
|
||||||
commentSats (existingCommentSats = 0) {
|
|
||||||
return existingCommentSats + sats
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onUpdate && onUpdate(cache, args)
|
|
||||||
}
|
}
|
||||||
}, [!!me, onUpdate])
|
}`
|
||||||
|
|
||||||
const [act] = useMutation(
|
export function useAct ({ onUpdate } = {}) {
|
||||||
gql`
|
const [act] = useMutation(ACT_MUTATION)
|
||||||
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) {
|
return act
|
||||||
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
|
|
||||||
id
|
|
||||||
sats
|
|
||||||
path
|
|
||||||
act
|
|
||||||
}
|
|
||||||
}`, { update }
|
|
||||||
)
|
|
||||||
return [act, update]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useZap () {
|
export function useZap () {
|
||||||
@ -307,118 +237,107 @@ export function useZap () {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const [zap] = useMutation(
|
const ZAP_MUTATION = gql`
|
||||||
gql`
|
mutation idempotentAct($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
|
||||||
mutation idempotentAct($id: ID!, $sats: Int!) {
|
act(id: $id, sats: $sats, hash: $hash, hmac: $hmac, idempotent: true) {
|
||||||
act(id: $id, sats: $sats, idempotent: true) {
|
id
|
||||||
id
|
sats
|
||||||
sats
|
path
|
||||||
path
|
}
|
||||||
}
|
}`
|
||||||
}`
|
const [zap] = useMutation(ZAP_MUTATION)
|
||||||
)
|
const me = useMe()
|
||||||
|
const { notify, unnotify } = useClientNotifications()
|
||||||
|
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const strike = useLightning()
|
const strike = useLightning()
|
||||||
const [act] = useAct()
|
const payment = usePayment()
|
||||||
const client = useApolloClient()
|
|
||||||
|
|
||||||
const invoiceableAct = useInvoiceModal(
|
return useCallback(async ({ item, mem, abortSignal }) => {
|
||||||
async ({ hash, hmac }, { variables, ...apolloArgs }) => {
|
|
||||||
await act({ variables: { ...variables, hash, hmac }, ...apolloArgs })
|
|
||||||
strike()
|
|
||||||
}, [act, strike])
|
|
||||||
|
|
||||||
const zapWithUndos = withToastFlow(toaster)(
|
|
||||||
({ variables, optimisticResponse, update, flowId }) => {
|
|
||||||
const { id: itemId, amount } = variables
|
|
||||||
let canceled
|
|
||||||
// update function for optimistic UX
|
|
||||||
const _update = () => {
|
|
||||||
const fragment = {
|
|
||||||
id: `Item:${itemId}`,
|
|
||||||
fragment: gql`
|
|
||||||
fragment ItemMeSatsUndos on Item {
|
|
||||||
sats
|
|
||||||
meSats
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
const item = client.cache.readFragment(fragment)
|
|
||||||
update(client.cache, { data: optimisticResponse })
|
|
||||||
// undo function
|
|
||||||
return () => client.cache.writeFragment({ ...fragment, data: item })
|
|
||||||
}
|
|
||||||
let undoUpdate
|
|
||||||
return {
|
|
||||||
flowId,
|
|
||||||
tag: itemId,
|
|
||||||
type: 'zap',
|
|
||||||
pendingMessage: `zapped ${amount} sats`,
|
|
||||||
onPending: () =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
undoUpdate = _update()
|
|
||||||
setTimeout(
|
|
||||||
() => {
|
|
||||||
if (canceled) return resolve()
|
|
||||||
zap({ variables, optimisticResponse, update: null }).then(resolve).catch((err) => {
|
|
||||||
undoUpdate()
|
|
||||||
reject(err)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
TOAST_DEFAULT_DELAY_MS
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
onUndo: () => {
|
|
||||||
// we can't simply clear the timeout on cancel since
|
|
||||||
// the onPending promise would never settle in that case
|
|
||||||
canceled = true
|
|
||||||
undoUpdate?.()
|
|
||||||
},
|
|
||||||
hideSuccess: true,
|
|
||||||
hideError: true,
|
|
||||||
timeout: TOAST_DEFAULT_DELAY_MS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return useCallback(async ({ item, me }) => {
|
|
||||||
const meSats = (item?.meSats || 0)
|
const meSats = (item?.meSats || 0)
|
||||||
|
|
||||||
// add current sats to next tip since idempotent zaps use desired total zap not difference
|
// add current sats to next tip since idempotent zaps use desired total zap not difference
|
||||||
const sats = meSats + nextTip(meSats, { ...me?.privates })
|
const sats = meSats + nextTip(meSats, { ...me?.privates })
|
||||||
const amount = sats - meSats
|
const satsDelta = sats - meSats
|
||||||
|
|
||||||
const variables = { id: item.id, sats, act: 'TIP', amount }
|
const variables = { id: item.id, sats, act: 'TIP' }
|
||||||
const insufficientFunds = me?.privates.sats < amount
|
const notifyProps = { itemId: item.id, sats: satsDelta }
|
||||||
const optimisticResponse = { act: { path: item.path, ...variables } }
|
const optimisticResponse = { act: { path: item.path, ...variables } }
|
||||||
const flowId = (+new Date()).toString(16)
|
|
||||||
const zapArgs = { variables, optimisticResponse: insufficientFunds ? null : optimisticResponse, update, flowId }
|
let revert, cancel, nid
|
||||||
try {
|
try {
|
||||||
if (insufficientFunds) throw new Error('insufficient funds')
|
revert = optimisticUpdate({ mutation: ZAP_MUTATION, variables, optimisticResponse, update })
|
||||||
strike()
|
strike()
|
||||||
if (zapUndosThresholdReached(me, amount)) {
|
|
||||||
await zapWithUndos(zapArgs)
|
await abortSignal.pause({ me, amount: satsDelta })
|
||||||
} else {
|
|
||||||
await zap(zapArgs)
|
if (me) {
|
||||||
|
nid = notify(ClientNotification.Zap.PENDING, notifyProps)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hash, hmac;
|
||||||
|
[{ hash, hmac }, cancel] = await payment.request(satsDelta)
|
||||||
|
await zap({ variables: { ...variables, hash, hmac } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (payOrLoginError(error)) {
|
revert?.()
|
||||||
// call non-idempotent version
|
|
||||||
const amount = sats - meSats
|
if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) {
|
||||||
optimisticResponse.act.amount = amount
|
|
||||||
try {
|
|
||||||
await invoiceableAct({ amount }, {
|
|
||||||
variables: { ...variables, sats: amount },
|
|
||||||
optimisticResponse,
|
|
||||||
update,
|
|
||||||
flowId
|
|
||||||
})
|
|
||||||
} catch (error) {}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error(error)
|
|
||||||
toaster.danger('zap: ' + error?.message || error?.toString?.())
|
const reason = error?.message || error?.toString?.()
|
||||||
|
if (me) {
|
||||||
|
notify(ClientNotification.Zap.ERROR, { ...notifyProps, reason })
|
||||||
|
} else {
|
||||||
|
toaster.danger('zap failed: ' + reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel?.()
|
||||||
|
} finally {
|
||||||
|
if (nid) unnotify(nid)
|
||||||
}
|
}
|
||||||
|
}, [me?.id, strike, payment, notify, unnotify])
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActCanceledError extends Error {
|
||||||
|
constructor () {
|
||||||
|
super('act canceled')
|
||||||
|
this.name = 'ActCanceledError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ZapUndoController extends AbortController {
|
||||||
|
constructor () {
|
||||||
|
super()
|
||||||
|
this.signal.start = () => { this.started = true }
|
||||||
|
this.signal.done = () => { this.done = true }
|
||||||
|
this.signal.pause = async ({ me, amount }) => {
|
||||||
|
if (zapUndoTrigger({ me, amount })) {
|
||||||
|
await zapUndo(this.signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zapUndoTrigger = ({ me, amount }) => {
|
||||||
|
if (!me) return false
|
||||||
|
const enabled = me.privates.zapUndos !== null
|
||||||
|
return enabled ? amount >= me.privates.zapUndos : false
|
||||||
|
}
|
||||||
|
|
||||||
|
const zapUndo = async (signal) => {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
signal.start()
|
||||||
|
const abortHandler = () => {
|
||||||
|
reject(new ActCanceledError())
|
||||||
|
signal.done()
|
||||||
|
signal.removeEventListener('abort', abortHandler)
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', abortHandler)
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve()
|
||||||
|
signal.done()
|
||||||
|
signal.removeEventListener('abort', abortHandler)
|
||||||
|
}, ZAP_UNDO_DELAY_MS)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -22,7 +22,7 @@ import Share from './share'
|
|||||||
import Toc from './table-of-contents'
|
import Toc from './table-of-contents'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { RootProvider } from './root'
|
import { RootProvider } from './root'
|
||||||
import { IMGPROXY_URL_REGEXP } from '@/lib/url'
|
import { 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'
|
||||||
@ -70,6 +70,7 @@ function ItemEmbed ({ item }) {
|
|||||||
const [overflowing, setOverflowing] = useState(false)
|
const [overflowing, setOverflowing] = useState(false)
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
|
|
||||||
|
// This Twitter embed could use similar logic to the video embeds below
|
||||||
const twitter = item.url?.match(/^https?:\/\/(?:twitter|x)\.com\/(?:#!\/)?\w+\/status(?:es)?\/(?<id>\d+)/)
|
const twitter = item.url?.match(/^https?:\/\/(?:twitter|x)\.com\/(?:#!\/)?\w+\/status(?:es)?\/(?<id>\d+)/)
|
||||||
if (twitter?.groups?.id) {
|
if (twitter?.groups?.id) {
|
||||||
return (
|
return (
|
||||||
@ -83,14 +84,15 @@ function ItemEmbed ({ item }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const youtube = item.url?.match(/(https?:\/\/)?((www\.)?(youtube(-nocookie)?|youtube.googleapis)\.com.*(v\/|v=|vi=|vi\/|e\/|embed\/|user\/.*\/u\/\d+\/)|youtu\.be\/)(?<id>[_0-9a-z-]+)((?:\?|&)(?:t|start)=(?<start>\d+))?/i)
|
const { provider, id, meta } = parseEmbedUrl(item.url)
|
||||||
if (youtube?.groups?.id) {
|
|
||||||
|
if (provider === 'youtube') {
|
||||||
return (
|
return (
|
||||||
<div className={styles.youtubeContainerContainer}>
|
<div className={styles.videoWrapper}>
|
||||||
<YouTube
|
<YouTube
|
||||||
videoId={youtube.groups.id} className={styles.youtubeContainer} opts={{
|
videoId={id} className={styles.videoContainer} opts={{
|
||||||
playerVars: {
|
playerVars: {
|
||||||
start: youtube?.groups?.start
|
start: meta?.start || 0
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -98,6 +100,20 @@ function ItemEmbed ({ item }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === 'rumble') {
|
||||||
|
return (
|
||||||
|
<div className={styles.videoWrapper}>
|
||||||
|
<div className={styles.videoContainer}>
|
||||||
|
<iframe
|
||||||
|
title='Rumble Video'
|
||||||
|
allowFullScreen=''
|
||||||
|
src={meta?.href}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (item.url?.match(IMGPROXY_URL_REGEXP)) {
|
if (item.url?.match(IMGPROXY_URL_REGEXP)) {
|
||||||
return <ZoomableImage src={item.url} rel={item.rel ?? UNKNOWN_LINK_REL} />
|
return <ZoomableImage src={item.url} rel={item.rel ?? UNKNOWN_LINK_REL} />
|
||||||
}
|
}
|
||||||
|
@ -176,7 +176,7 @@ export default function ItemInfo ({
|
|||||||
!item.mine && !item.deletedAt &&
|
!item.mine && !item.deletedAt &&
|
||||||
(item.meDontLikeSats > meTotalSats
|
(item.meDontLikeSats > meTotalSats
|
||||||
? <DropdownItemUpVote item={item} />
|
? <DropdownItemUpVote item={item} />
|
||||||
: <DontLikeThisDropdownItem id={item.id} />)}
|
: <DontLikeThisDropdownItem item={item} />)}
|
||||||
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
|
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
|
||||||
<>
|
<>
|
||||||
<hr className='dropdown-divider' />
|
<hr className='dropdown-divider' />
|
||||||
|
@ -63,7 +63,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
|
|||||||
{item.position && (pinnable || !item.subName)
|
{item.position && (pinnable || !item.subName)
|
||||||
? <Pin width={24} height={24} className={styles.pin} />
|
? <Pin width={24} height={24} className={styles.pin} />
|
||||||
: item.meDontLikeSats > item.meSats
|
: item.meDontLikeSats > item.meSats
|
||||||
? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} />
|
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
||||||
: Number(item.user?.id) === AD_USER_ID
|
: Number(item.user?.id) === AD_USER_ID
|
||||||
? <AdIcon width={24} height={24} className={styles.ad} />
|
? <AdIcon width={24} height={24} className={styles.ad} />
|
||||||
: <UpVote item={item} className={styles.upvote} />}
|
: <UpVote item={item} className={styles.upvote} />}
|
||||||
|
@ -105,7 +105,8 @@ export default function JobForm ({ item, sub }) {
|
|||||||
}}
|
}}
|
||||||
schema={jobSchema}
|
schema={jobSchema}
|
||||||
storageKeyPrefix={storageKeyPrefix}
|
storageKeyPrefix={storageKeyPrefix}
|
||||||
invoiceable={{ requireSession: true }}
|
requireSession
|
||||||
|
prepaid
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
<div className='form-group'>
|
<div className='form-group'>
|
||||||
|
@ -143,7 +143,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||||
}}
|
}}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
invoiceable
|
prepaid
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
storageKeyPrefix={storageKeyPrefix}
|
storageKeyPrefix={storageKeyPrefix}
|
||||||
>
|
>
|
||||||
|
@ -30,12 +30,19 @@ import { nextBillingWithGrace } from '@/lib/territory'
|
|||||||
import { commentSubTreeRootId } from '@/lib/item'
|
import { commentSubTreeRootId } from '@/lib/item'
|
||||||
import LinkToContext from './link-to-context'
|
import LinkToContext from './link-to-context'
|
||||||
import { Badge } from 'react-bootstrap'
|
import { Badge } from 'react-bootstrap'
|
||||||
|
import { Types as ClientTypes, ClientZap, ClientReply, ClientPollVote, ClientBounty, useClientNotifications } from './client-notifications'
|
||||||
|
import { ITEM_FULL } from '@/fragments/items'
|
||||||
|
|
||||||
function Notification ({ n, fresh }) {
|
function Notification ({ n, fresh }) {
|
||||||
const type = n.__typename
|
const type = n.__typename
|
||||||
|
|
||||||
|
// we need to resolve item id to item to show item for client notifications
|
||||||
|
const { data } = useQuery(ITEM_FULL, { variables: { id: n.itemId }, skip: !n.itemId })
|
||||||
|
const item = data?.item
|
||||||
|
const itemN = { item, ...n }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
|
<NotificationLayout nid={nid(n)} {...defaultOnClick(itemN)} fresh={fresh}>
|
||||||
{
|
{
|
||||||
(type === 'Earn' && <EarnNotification n={n} />) ||
|
(type === 'Earn' && <EarnNotification n={n} />) ||
|
||||||
(type === 'Revenue' && <RevenueNotification n={n} />) ||
|
(type === 'Revenue' && <RevenueNotification n={n} />) ||
|
||||||
@ -53,7 +60,11 @@ function Notification ({ n, fresh }) {
|
|||||||
(type === 'FollowActivity' && <FollowActivity n={n} />) ||
|
(type === 'FollowActivity' && <FollowActivity n={n} />) ||
|
||||||
(type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
|
(type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
|
||||||
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) ||
|
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) ||
|
||||||
(type === 'Reminder' && <Reminder n={n} />)
|
(type === 'Reminder' && <Reminder n={n} />) ||
|
||||||
|
([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(type) && <ClientZap n={itemN} />) ||
|
||||||
|
([ClientTypes.Reply.ERROR, ClientTypes.Reply.PENDING].includes(type) && <ClientReply n={itemN} />) ||
|
||||||
|
([ClientTypes.Bounty.ERROR, ClientTypes.Bounty.PENDING].includes(type) && <ClientBounty n={itemN} />) ||
|
||||||
|
([ClientTypes.PollVote.ERROR, ClientTypes.PollVote.PENDING].includes(type) && <ClientPollVote n={itemN} />)
|
||||||
}
|
}
|
||||||
</NotificationLayout>
|
</NotificationLayout>
|
||||||
)
|
)
|
||||||
@ -102,6 +113,8 @@ const defaultOnClick = n => {
|
|||||||
if (type === 'Streak') return {}
|
if (type === 'Streak') return {}
|
||||||
if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }
|
if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }
|
||||||
|
|
||||||
|
if (!n.item) return {}
|
||||||
|
|
||||||
// Votification, Mention, JobChanged, Reply all have item
|
// Votification, Mention, JobChanged, Reply all have item
|
||||||
if (!n.item.title) {
|
if (!n.item.title) {
|
||||||
const rootId = commentSubTreeRootId(n.item)
|
const rootId = commentSubTreeRootId(n.item)
|
||||||
@ -534,6 +547,7 @@ export default function Notifications ({ ssrData }) {
|
|||||||
const { data, fetchMore } = useQuery(NOTIFICATIONS)
|
const { data, fetchMore } = useQuery(NOTIFICATIONS)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const dat = useData(data, ssrData)
|
const dat = useData(data, ssrData)
|
||||||
|
const { notifications: clientNotifications } = useClientNotifications()
|
||||||
|
|
||||||
const { notifications, lastChecked, cursor } = useMemo(() => {
|
const { notifications, lastChecked, cursor } = useMemo(() => {
|
||||||
if (!dat?.notifications) return {}
|
if (!dat?.notifications) return {}
|
||||||
@ -561,9 +575,12 @@ export default function Notifications ({ ssrData }) {
|
|||||||
|
|
||||||
if (!dat) return <CommentsFlatSkeleton />
|
if (!dat) return <CommentsFlatSkeleton />
|
||||||
|
|
||||||
|
const sorted = [...clientNotifications, ...notifications]
|
||||||
|
.sort((a, b) => new Date(b.sortTime).getTime() - new Date(a.sortTime).getTime())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{notifications.map(n =>
|
{sorted.map(n =>
|
||||||
<Notification
|
<Notification
|
||||||
n={n} key={nid(n)}
|
n={n} key={nid(n)}
|
||||||
fresh={new Date(n.sortTime) > new Date(router?.query?.checkedAt ?? lastChecked)}
|
fresh={new Date(n.sortTime) > new Date(router?.query?.checkedAt ?? lastChecked)}
|
||||||
|
@ -6,15 +6,23 @@ import { useMe } from './me'
|
|||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { useRoot } from './root'
|
import { useRoot } from './root'
|
||||||
import { payOrLoginError, useInvoiceModal } from './invoice'
|
import { useAct, actUpdate, ACT_MUTATION } from './item-act'
|
||||||
import { useAct } from './item-act'
|
import { InvoiceCanceledError, usePayment } from './payment'
|
||||||
|
import { optimisticUpdate } from '@/lib/apollo'
|
||||||
|
import { useLightning } from './lightning'
|
||||||
|
import { useToast } from './toast'
|
||||||
|
import { Types as ClientNotification, useClientNotifications } from './client-notifications'
|
||||||
|
|
||||||
export default function PayBounty ({ children, item }) {
|
export default function PayBounty ({ children, item }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
const root = useRoot()
|
const root = useRoot()
|
||||||
|
const payment = usePayment()
|
||||||
|
const strike = useLightning()
|
||||||
|
const toaster = useToast()
|
||||||
|
const { notify, unnotify } = useClientNotifications()
|
||||||
|
|
||||||
const onUpdate = useCallback((cache, { data: { act: { id, path } } }) => {
|
const onUpdate = useCallback(onComplete => (cache, { data: { act: { id, path } } }) => {
|
||||||
// update root bounty status
|
// update root bounty status
|
||||||
const root = path.split('.')[0]
|
const root = path.split('.')[0]
|
||||||
cache.modify({
|
cache.modify({
|
||||||
@ -25,30 +33,55 @@ export default function PayBounty ({ children, item }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [])
|
strike()
|
||||||
|
onComplete()
|
||||||
|
}, [strike])
|
||||||
|
|
||||||
const [act] = useAct({ onUpdate })
|
const act = useAct()
|
||||||
|
|
||||||
const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => {
|
|
||||||
await act({ variables: { ...variables, hash, hmac } })
|
|
||||||
}, [act])
|
|
||||||
|
|
||||||
const handlePayBounty = async onComplete => {
|
const handlePayBounty = async onComplete => {
|
||||||
const variables = { id: item.id, sats: root.bounty, act: 'TIP', path: item.path }
|
const sats = root.bounty
|
||||||
|
const variables = { id: item.id, sats, act: 'TIP', path: item.path }
|
||||||
|
const notifyProps = { itemId: item.id, sats }
|
||||||
|
const optimisticResponse = { act: { ...variables, path: item.path } }
|
||||||
|
|
||||||
|
let revert, cancel, nid
|
||||||
try {
|
try {
|
||||||
await act({
|
revert = optimisticUpdate({
|
||||||
|
mutation: ACT_MUTATION,
|
||||||
variables,
|
variables,
|
||||||
|
optimisticResponse,
|
||||||
|
update: actUpdate({ me, onUpdate: onUpdate(onComplete) })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (me) {
|
||||||
|
nid = notify(ClientNotification.Bounty.PENDING, notifyProps)
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash, hmac;
|
||||||
|
[{ hash, hmac }, cancel] = await payment.request(sats)
|
||||||
|
await act({
|
||||||
|
variables: { hash, hmac, ...variables },
|
||||||
optimisticResponse: {
|
optimisticResponse: {
|
||||||
act: variables
|
act: variables
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onComplete()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (payOrLoginError(error)) {
|
revert?.()
|
||||||
showInvoiceModal({ amount: root.bounty }, { variables })
|
|
||||||
|
if (error instanceof InvoiceCanceledError) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
throw new Error({ message: error.toString() })
|
|
||||||
|
const reason = error?.message || error?.toString?.()
|
||||||
|
if (me) {
|
||||||
|
notify(ClientNotification.Bounty.ERROR, { ...notifyProps, reason })
|
||||||
|
} else {
|
||||||
|
toaster.danger('pay bounty failed: ' + reason)
|
||||||
|
}
|
||||||
|
cancel?.()
|
||||||
|
} finally {
|
||||||
|
if (nid) unnotify(nid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
212
components/payment.js
Normal file
212
components/payment.js
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useMe } from './me'
|
||||||
|
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||||
|
import { useWebLN } from './webln'
|
||||||
|
import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
|
||||||
|
import { INVOICE } from '@/fragments/wallet'
|
||||||
|
import Invoice from '@/components/invoice'
|
||||||
|
import { useFeeButton } from './fee-button'
|
||||||
|
import { useShowModal } from './modal'
|
||||||
|
|
||||||
|
export class InvoiceCanceledError extends Error {
|
||||||
|
constructor (hash) {
|
||||||
|
super(`invoice canceled: ${hash}`)
|
||||||
|
this.name = 'InvoiceCanceledError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebLnNotEnabledError extends Error {
|
||||||
|
constructor () {
|
||||||
|
super('no enabled WebLN provider found')
|
||||||
|
this.name = 'WebLnNotEnabledError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvoiceExpiredError extends Error {
|
||||||
|
constructor (hash) {
|
||||||
|
super(`invoice expired: ${hash}`)
|
||||||
|
this.name = 'InvoiceExpiredError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useInvoice = () => {
|
||||||
|
const client = useApolloClient()
|
||||||
|
|
||||||
|
const [createInvoice] = useMutation(gql`
|
||||||
|
mutation createInvoice($amount: Int!, $expireSecs: Int!) {
|
||||||
|
createInvoice(amount: $amount, hodlInvoice: true, expireSecs: $expireSecs) {
|
||||||
|
id
|
||||||
|
bolt11
|
||||||
|
hash
|
||||||
|
hmac
|
||||||
|
expiresAt
|
||||||
|
satsRequested
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
const [cancelInvoice] = useMutation(gql`
|
||||||
|
mutation cancelInvoice($hash: String!, $hmac: String!) {
|
||||||
|
cancelInvoice(hash: $hash, hmac: $hmac) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
const create = useCallback(async amount => {
|
||||||
|
const { data, error } = await createInvoice({ variables: { amount, expireSecs: JIT_INVOICE_TIMEOUT_MS / 1000 } })
|
||||||
|
if (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const invoice = data.createInvoice
|
||||||
|
return invoice
|
||||||
|
}, [createInvoice])
|
||||||
|
|
||||||
|
const isPaid = useCallback(async id => {
|
||||||
|
const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'no-cache', variables: { id } })
|
||||||
|
if (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const { hash, isHeld, satsReceived, cancelled } = data.invoice
|
||||||
|
// if we're polling for invoices, we're using JIT invoices so isHeld must be set
|
||||||
|
if (isHeld && satsReceived) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (cancelled) {
|
||||||
|
throw new InvoiceCanceledError(hash)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [client])
|
||||||
|
|
||||||
|
const waitUntilPaid = useCallback(async id => {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const paid = await isPaid(id)
|
||||||
|
if (paid) {
|
||||||
|
resolve()
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, FAST_POLL_INTERVAL)
|
||||||
|
})
|
||||||
|
}, [isPaid])
|
||||||
|
|
||||||
|
const cancel = useCallback(async ({ hash, hmac }) => {
|
||||||
|
const inv = await cancelInvoice({ variables: { hash, hmac } })
|
||||||
|
console.log('invoice canceled:', hash)
|
||||||
|
return inv
|
||||||
|
}, [cancelInvoice])
|
||||||
|
|
||||||
|
return { create, isPaid, waitUntilPaid, cancel }
|
||||||
|
}
|
||||||
|
|
||||||
|
const useWebLnPayment = () => {
|
||||||
|
const invoice = useInvoice()
|
||||||
|
const provider = useWebLN()
|
||||||
|
|
||||||
|
const waitForWebLnPayment = useCallback(async ({ id, bolt11 }) => {
|
||||||
|
if (!provider) {
|
||||||
|
throw new WebLnNotEnabledError()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
// can't use await here since we might pay JIT invoices and sendPaymentAsync is not supported yet.
|
||||||
|
// see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync
|
||||||
|
provider.sendPayment(bolt11)
|
||||||
|
// JIT invoice payments will never resolve here
|
||||||
|
// since they only get resolved after settlement which can't happen here
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject)
|
||||||
|
invoice.waitUntilPaid(id)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('WebLN payment failed:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}, [provider, invoice])
|
||||||
|
|
||||||
|
return waitForWebLnPayment
|
||||||
|
}
|
||||||
|
|
||||||
|
const useQrPayment = () => {
|
||||||
|
const invoice = useInvoice()
|
||||||
|
const showModal = useShowModal()
|
||||||
|
|
||||||
|
const waitForQrPayment = useCallback(async (inv, webLnError) => {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
let paid
|
||||||
|
const cancelAndReject = async (onClose) => {
|
||||||
|
if (paid) return
|
||||||
|
await invoice.cancel(inv)
|
||||||
|
reject(new InvoiceCanceledError(inv.hash))
|
||||||
|
}
|
||||||
|
showModal(onClose =>
|
||||||
|
<Invoice
|
||||||
|
invoice={inv}
|
||||||
|
modal
|
||||||
|
successVerb='received'
|
||||||
|
webLn={false}
|
||||||
|
webLnError={webLnError}
|
||||||
|
onPayment={() => { paid = true; onClose(); resolve() }}
|
||||||
|
poll
|
||||||
|
/>,
|
||||||
|
{ keepOpen: true, onClose: cancelAndReject })
|
||||||
|
})
|
||||||
|
}, [invoice])
|
||||||
|
|
||||||
|
return waitForQrPayment
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePayment = () => {
|
||||||
|
const me = useMe()
|
||||||
|
const feeButton = useFeeButton()
|
||||||
|
const invoice = useInvoice()
|
||||||
|
const waitForWebLnPayment = useWebLnPayment()
|
||||||
|
const waitForQrPayment = useQrPayment()
|
||||||
|
|
||||||
|
const waitForPayment = useCallback(async (invoice) => {
|
||||||
|
let webLnError
|
||||||
|
try {
|
||||||
|
return await waitForWebLnPayment(invoice)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) {
|
||||||
|
// bail since qr code payment will also fail
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
webLnError = err
|
||||||
|
}
|
||||||
|
return await waitForQrPayment(invoice, webLnError)
|
||||||
|
}, [waitForWebLnPayment, waitForQrPayment])
|
||||||
|
|
||||||
|
const request = useCallback(async (amount) => {
|
||||||
|
amount ??= feeButton?.total
|
||||||
|
const free = feeButton?.free
|
||||||
|
const balance = me ? me.privates.sats : 0
|
||||||
|
|
||||||
|
// if user has enough funds in their custodial wallet or action is free, never prompt for payment
|
||||||
|
// XXX this will probably not work as intended for deposits < balance
|
||||||
|
// which means you can't always fund your custodial wallet with attached wallets ...
|
||||||
|
// but should this even be the case?
|
||||||
|
const insufficientFunds = balance < amount
|
||||||
|
if (free || !insufficientFunds) return [{ hash: null, hmac: null }, null]
|
||||||
|
|
||||||
|
const inv = await invoice.create(amount)
|
||||||
|
|
||||||
|
await waitForPayment(inv)
|
||||||
|
|
||||||
|
const cancel = () => invoice.cancel(inv).catch(console.error)
|
||||||
|
return [inv, cancel]
|
||||||
|
}, [me, feeButton?.total, invoice, waitForPayment])
|
||||||
|
|
||||||
|
const cancel = useCallback(({ hash, hmac }) => {
|
||||||
|
if (hash && hmac) {
|
||||||
|
invoice.cancel({ hash, hmac }).catch(console.error)
|
||||||
|
}
|
||||||
|
}, [invoice])
|
||||||
|
|
||||||
|
return { request, cancel }
|
||||||
|
}
|
@ -86,7 +86,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||||
}}
|
}}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
invoiceable
|
prepaid
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
storageKeyPrefix={storageKeyPrefix}
|
storageKeyPrefix={storageKeyPrefix}
|
||||||
>
|
>
|
||||||
|
@ -8,68 +8,85 @@ import Check from '@/svgs/checkbox-circle-fill.svg'
|
|||||||
import { signIn } from 'next-auth/react'
|
import { signIn } from 'next-auth/react'
|
||||||
import ActionTooltip from './action-tooltip'
|
import ActionTooltip from './action-tooltip'
|
||||||
import { POLL_COST } from '@/lib/constants'
|
import { POLL_COST } from '@/lib/constants'
|
||||||
import { payOrLoginError, useInvoiceModal } from './invoice'
|
import { InvoiceCanceledError, usePayment } from './payment'
|
||||||
|
import { optimisticUpdate } from '@/lib/apollo'
|
||||||
|
import { useToast } from './toast'
|
||||||
|
import { Types as ClientNotification, useClientNotifications } from './client-notifications'
|
||||||
|
|
||||||
export default function Poll ({ item }) {
|
export default function Poll ({ item }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const [pollVote] = useMutation(
|
const POLL_VOTE_MUTATION = gql`
|
||||||
gql`
|
mutation pollVote($id: ID!, $hash: String, $hmac: String) {
|
||||||
mutation pollVote($id: ID!, $hash: String, $hmac: String) {
|
pollVote(id: $id, hash: $hash, hmac: $hmac)
|
||||||
pollVote(id: $id, hash: $hash, hmac: $hmac)
|
}`
|
||||||
}`, {
|
const [pollVote] = useMutation(POLL_VOTE_MUTATION)
|
||||||
update (cache, { data: { pollVote } }) {
|
const toaster = useToast()
|
||||||
cache.modify({
|
const { notify, unnotify } = useClientNotifications()
|
||||||
id: `Item:${item.id}`,
|
|
||||||
fields: {
|
const update = (cache, { data: { pollVote } }) => {
|
||||||
poll (existingPoll) {
|
cache.modify({
|
||||||
const poll = { ...existingPoll }
|
id: `Item:${item.id}`,
|
||||||
poll.meVoted = true
|
fields: {
|
||||||
poll.count += 1
|
poll (existingPoll) {
|
||||||
return poll
|
const poll = { ...existingPoll }
|
||||||
}
|
poll.meVoted = true
|
||||||
}
|
poll.count += 1
|
||||||
})
|
return poll
|
||||||
cache.modify({
|
}
|
||||||
id: `PollOption:${pollVote}`,
|
|
||||||
fields: {
|
|
||||||
count (existingCount) {
|
|
||||||
return existingCount + 1
|
|
||||||
},
|
|
||||||
meVoted () {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
)
|
cache.modify({
|
||||||
|
id: `PollOption:${pollVote}`,
|
||||||
|
fields: {
|
||||||
|
count (existingCount) {
|
||||||
|
return existingCount + 1
|
||||||
|
},
|
||||||
|
meVoted () {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const PollButton = ({ v }) => {
|
const PollButton = ({ v }) => {
|
||||||
const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => {
|
const payment = usePayment()
|
||||||
await pollVote({ variables: { ...variables, hash, hmac } })
|
|
||||||
}, [pollVote])
|
|
||||||
|
|
||||||
const variables = { id: v.id }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionTooltip placement='left' notForm overlayText='1 sat'>
|
<ActionTooltip placement='left' notForm overlayText='1 sat'>
|
||||||
<Button
|
<Button
|
||||||
variant='outline-info' className={styles.pollButton}
|
variant='outline-info' className={styles.pollButton}
|
||||||
onClick={me
|
onClick={me
|
||||||
? async () => {
|
? async () => {
|
||||||
|
const variables = { id: v.id }
|
||||||
|
const notifyProps = { itemId: item.id }
|
||||||
|
const optimisticResponse = { pollVote: v.id }
|
||||||
|
let revert, cancel, nid
|
||||||
try {
|
try {
|
||||||
await pollVote({
|
revert = optimisticUpdate({ mutation: POLL_VOTE_MUTATION, variables, optimisticResponse, update })
|
||||||
variables,
|
|
||||||
optimisticResponse: {
|
if (me) {
|
||||||
pollVote: v.id
|
nid = notify(ClientNotification.PollVote.PENDING, notifyProps)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
let hash, hmac;
|
||||||
|
[{ hash, hmac }, cancel] = await payment.request(item.pollCost || POLL_COST)
|
||||||
|
await pollVote({ variables: { hash, hmac, ...variables } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (payOrLoginError(error)) {
|
revert?.()
|
||||||
showInvoiceModal({ amount: item.pollCost || POLL_COST }, { variables })
|
|
||||||
|
if (error instanceof InvoiceCanceledError) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
throw new Error({ message: error.toString() })
|
|
||||||
|
const reason = error?.message || error?.toString?.()
|
||||||
|
if (me) {
|
||||||
|
notify(ClientNotification.PollVote.ERROR, { ...notifyProps, reason })
|
||||||
|
} else {
|
||||||
|
toaster.danger('poll vote failed: ' + reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel?.()
|
||||||
|
} finally {
|
||||||
|
if (nid) unnotify(nid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: signIn}
|
: signIn}
|
||||||
|
@ -14,7 +14,7 @@ export default function Qr ({ asIs, value, webLn, statusVariant, description, st
|
|||||||
async function effect () {
|
async function effect () {
|
||||||
if (webLn && provider) {
|
if (webLn && provider) {
|
||||||
try {
|
try {
|
||||||
await provider.sendPayment({ bolt11: value })
|
await provider.sendPayment(value)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e?.message)
|
console.log(e?.message)
|
||||||
}
|
}
|
||||||
|
@ -166,7 +166,7 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
|
|||||||
text: ''
|
text: ''
|
||||||
}}
|
}}
|
||||||
schema={commentSchema}
|
schema={commentSchema}
|
||||||
invoiceable
|
prepaid
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
storageKeyPrefix={`reply-${parentId}`}
|
storageKeyPrefix={`reply-${parentId}`}
|
||||||
>
|
>
|
||||||
|
@ -112,7 +112,7 @@ export default function TerritoryForm ({ sub }) {
|
|||||||
nsfw: sub?.nsfw || false
|
nsfw: sub?.nsfw || false
|
||||||
}}
|
}}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
invoiceable
|
prepaid
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
className='mb-5'
|
className='mb-5'
|
||||||
storageKeyPrefix={sub ? undefined : 'territory'}
|
storageKeyPrefix={sub ? undefined : 'territory'}
|
||||||
|
@ -56,7 +56,7 @@ export default function TerritoryPaymentDue ({ sub }) {
|
|||||||
|
|
||||||
<FeeButtonProvider baseLineItems={{ territory: TERRITORY_BILLING_OPTIONS('one')[sub.billingType.toLowerCase()] }}>
|
<FeeButtonProvider baseLineItems={{ territory: TERRITORY_BILLING_OPTIONS('one')[sub.billingType.toLowerCase()] }}>
|
||||||
<Form
|
<Form
|
||||||
invoiceable
|
prepaid
|
||||||
initial={{
|
initial={{
|
||||||
name: sub.name
|
name: sub.name
|
||||||
}}
|
}}
|
||||||
|
@ -13,7 +13,7 @@ import Thumb from '@/svgs/thumb-up-fill.svg'
|
|||||||
import { toString } from 'mdast-util-to-string'
|
import { toString } from 'mdast-util-to-string'
|
||||||
import copy from 'clipboard-copy'
|
import copy from 'clipboard-copy'
|
||||||
import ZoomableImage, { decodeOriginalUrl } from './image'
|
import ZoomableImage, { decodeOriginalUrl } from './image'
|
||||||
import { IMGPROXY_URL_REGEXP, parseInternalLinks } from '@/lib/url'
|
import { IMGPROXY_URL_REGEXP, parseInternalLinks, parseEmbedUrl } from '@/lib/url'
|
||||||
import reactStringReplace from 'react-string-replace'
|
import reactStringReplace from 'react-string-replace'
|
||||||
import { rehypeInlineCodeProperty } from '@/lib/md'
|
import { rehypeInlineCodeProperty } from '@/lib/md'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
@ -238,15 +238,22 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||||||
// ignore errors like invalid URLs
|
// ignore errors like invalid URLs
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the link is to a youtube video, render the video
|
const videoWrapperStyles = {
|
||||||
const youtube = href.match(/(https?:\/\/)?((www\.)?(youtube(-nocookie)?|youtube.googleapis)\.com.*(v\/|v=|vi=|vi\/|e\/|embed\/|user\/.*\/u\/\d+\/)|youtu\.be\/)(?<id>[_0-9a-z-]+)((?:\?|&)(?:t|start)=(?<start>\d+))?/i)
|
maxWidth: topLevel ? '640px' : '320px',
|
||||||
if (youtube?.groups?.id) {
|
margin: '0.5rem 0',
|
||||||
|
paddingRight: '15px'
|
||||||
|
}
|
||||||
|
|
||||||
|
const { provider, id, meta } = parseEmbedUrl(href)
|
||||||
|
|
||||||
|
// Youtube video embed
|
||||||
|
if (provider === 'youtube') {
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: topLevel ? '640px' : '320px', paddingRight: '15px', margin: '0.5rem 0' }}>
|
<div style={videoWrapperStyles}>
|
||||||
<YouTube
|
<YouTube
|
||||||
videoId={youtube.groups.id} className={styles.youtubeContainer} opts={{
|
videoId={id} className={styles.videoContainer} opts={{
|
||||||
playerVars: {
|
playerVars: {
|
||||||
start: youtube?.groups?.start
|
start: meta?.start || 0
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -254,6 +261,21 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rumble video embed
|
||||||
|
if (provider === 'rumble') {
|
||||||
|
return (
|
||||||
|
<div style={videoWrapperStyles}>
|
||||||
|
<div className={styles.videoContainer}>
|
||||||
|
<iframe
|
||||||
|
title='Rumble Video'
|
||||||
|
allowFullScreen=''
|
||||||
|
src={meta?.href}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// assume the link is an image which will fallback to link if it's not
|
// assume the link is an image which will fallback to link if it's not
|
||||||
return <Img src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</Img>
|
return <Img src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</Img>
|
||||||
},
|
},
|
||||||
|
@ -237,7 +237,7 @@ img.fullScreen {
|
|||||||
font-size: .85rem;
|
font-size: .85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.youtubeContainer {
|
.videoContainer {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 0;
|
height: 0;
|
||||||
@ -245,7 +245,7 @@ img.fullScreen {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.youtubeContainer iframe {
|
.videoContainer iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -10,21 +10,6 @@ const ToastContext = createContext(() => {})
|
|||||||
|
|
||||||
export const TOAST_DEFAULT_DELAY_MS = 5000
|
export const TOAST_DEFAULT_DELAY_MS = 5000
|
||||||
|
|
||||||
const ensureFlow = (toasts, newToast) => {
|
|
||||||
const { flowId } = newToast
|
|
||||||
if (flowId) {
|
|
||||||
// replace previous toast with same flow id
|
|
||||||
const idx = toasts.findIndex(toast => toast.flowId === flowId)
|
|
||||||
if (idx === -1) return [...toasts, newToast]
|
|
||||||
return [
|
|
||||||
...toasts.slice(0, idx),
|
|
||||||
newToast,
|
|
||||||
...toasts.slice(idx + 1)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return [...toasts, newToast]
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapHidden = ({ id, tag }) => toast => {
|
const mapHidden = ({ id, tag }) => toast => {
|
||||||
// mark every previous toast with same tag as hidden
|
// mark every previous toast with same tag as hidden
|
||||||
if (toast.tag === tag && toast.id !== id) return { ...toast, hidden: true }
|
if (toast.tag === tag && toast.id !== id) return { ...toast, hidden: true }
|
||||||
@ -36,24 +21,15 @@ export const ToastProvider = ({ children }) => {
|
|||||||
const [toasts, setToasts] = useState([])
|
const [toasts, setToasts] = useState([])
|
||||||
const toastId = useRef(0)
|
const toastId = useRef(0)
|
||||||
|
|
||||||
const removeToast = useCallback(({ id, onCancel, tag }) => {
|
const removeToast = useCallback(({ id, tag }) => {
|
||||||
setToasts(toasts => toasts.filter(toast => {
|
setToasts(toasts => toasts.filter(toast => {
|
||||||
if (toast.id === id) {
|
if (toast.id === id) {
|
||||||
// remove the toast with the passed id with no exceptions
|
// remove the toast with the passed id with no exceptions
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const sameTag = tag && tag === toast.tag
|
const sameTag = tag && tag === toast.tag
|
||||||
if (!sameTag) {
|
// remove toasts with same tag
|
||||||
// don't touch toasts with different tags
|
return !sameTag
|
||||||
return true
|
|
||||||
}
|
|
||||||
const toRemoveHasCancel = !!toast.onCancel || !!toast.onUndo
|
|
||||||
if (toRemoveHasCancel) {
|
|
||||||
// don't remove this toast so the user can decide to cancel this toast now
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// remove toasts with same tag if they are not cancelable
|
|
||||||
return false
|
|
||||||
}))
|
}))
|
||||||
}, [setToasts])
|
}, [setToasts])
|
||||||
|
|
||||||
@ -63,14 +39,10 @@ export const ToastProvider = ({ children }) => {
|
|||||||
createdAt: +new Date(),
|
createdAt: +new Date(),
|
||||||
id: toastId.current++
|
id: toastId.current++
|
||||||
}
|
}
|
||||||
setToasts(toasts => ensureFlow(toasts, toast).map(mapHidden(toast)))
|
setToasts(toasts => [...toasts, toast].map(mapHidden(toast)))
|
||||||
return () => removeToast(toast)
|
return () => removeToast(toast)
|
||||||
}, [setToasts, removeToast])
|
}, [setToasts, removeToast])
|
||||||
|
|
||||||
const endFlow = useCallback((flowId) => {
|
|
||||||
setToasts(toasts => toasts.filter(toast => toast.flowId !== flowId))
|
|
||||||
}, [setToasts])
|
|
||||||
|
|
||||||
const toaster = useMemo(() => ({
|
const toaster = useMemo(() => ({
|
||||||
success: (body, options) => {
|
success: (body, options) => {
|
||||||
const toast = {
|
const toast = {
|
||||||
@ -103,14 +75,13 @@ export const ToastProvider = ({ children }) => {
|
|||||||
...options
|
...options
|
||||||
}
|
}
|
||||||
return dispatchToast(toast)
|
return dispatchToast(toast)
|
||||||
},
|
}
|
||||||
endFlow
|
}), [dispatchToast, removeToast])
|
||||||
}), [dispatchToast, removeToast, endFlow])
|
|
||||||
|
|
||||||
// Only clear toasts with no cancel function on page navigation
|
// Only clear toasts with no cancel function on page navigation
|
||||||
// since navigation should not interfere with being able to cancel an action.
|
// since navigation should not interfere with being able to cancel an action.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRouteChangeStart = () => setToasts(toasts => toasts.length > 0 ? toasts.filter(({ onCancel, onUndo, persistOnNavigate }) => onCancel || onUndo || persistOnNavigate) : toasts)
|
const handleRouteChangeStart = () => setToasts(toasts => toasts.length > 0 ? toasts.filter(({ persistOnNavigate }) => persistOnNavigate) : toasts)
|
||||||
router.events.on('routeChangeStart', handleRouteChangeStart)
|
router.events.on('routeChangeStart', handleRouteChangeStart)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -151,16 +122,9 @@ export const ToastProvider = ({ children }) => {
|
|||||||
{visibleToasts.map(toast => {
|
{visibleToasts.map(toast => {
|
||||||
const textStyle = toast.variant === 'warning' ? 'text-dark' : ''
|
const textStyle = toast.variant === 'warning' ? 'text-dark' : ''
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
toast.onUndo?.()
|
|
||||||
toast.onCancel?.()
|
|
||||||
toast.onClose?.()
|
toast.onClose?.()
|
||||||
removeToast(toast)
|
removeToast(toast)
|
||||||
}
|
}
|
||||||
const buttonElement = toast.onUndo
|
|
||||||
? <div className={`${styles.toastUndo} ${textStyle}`}>undo</div>
|
|
||||||
: toast.onCancel
|
|
||||||
? <div className={`${styles.toastCancel} ${textStyle}`}>cancel</div>
|
|
||||||
: <div className={`${styles.toastClose} ${textStyle}`}>X</div>
|
|
||||||
// a toast is unhidden if it was hidden before since it now gets rendered
|
// a toast is unhidden if it was hidden before since it now gets rendered
|
||||||
const unhidden = toast.hidden
|
const unhidden = toast.hidden
|
||||||
// we only need to start the animation at a different timing when it was hidden by another toast before.
|
// we only need to start the animation at a different timing when it was hidden by another toast before.
|
||||||
@ -181,7 +145,7 @@ export const ToastProvider = ({ children }) => {
|
|||||||
className='p-0 ps-2'
|
className='p-0 ps-2'
|
||||||
aria-label='close'
|
aria-label='close'
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>{buttonElement}
|
><div className={`${styles.toastClose} ${textStyle}`}>X</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</ToastBody>
|
</ToastBody>
|
||||||
@ -196,78 +160,3 @@ export const ToastProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useToast = () => useContext(ToastContext)
|
export const useToast = () => useContext(ToastContext)
|
||||||
|
|
||||||
export const withToastFlow = (toaster) => flowFn => {
|
|
||||||
const wrapper = async (...args) => {
|
|
||||||
const {
|
|
||||||
flowId,
|
|
||||||
type: t,
|
|
||||||
onPending,
|
|
||||||
pendingMessage,
|
|
||||||
onSuccess,
|
|
||||||
onCancel,
|
|
||||||
onError,
|
|
||||||
onUndo,
|
|
||||||
hideError,
|
|
||||||
hideSuccess,
|
|
||||||
skipToastFlow,
|
|
||||||
timeout,
|
|
||||||
...toastProps
|
|
||||||
} = flowFn(...args)
|
|
||||||
let canceled
|
|
||||||
|
|
||||||
if (skipToastFlow) return onPending()
|
|
||||||
|
|
||||||
toaster.warning(pendingMessage || `${t} pending`, {
|
|
||||||
progressBar: !!timeout,
|
|
||||||
delay: timeout || TOAST_DEFAULT_DELAY_MS,
|
|
||||||
onCancel: onCancel
|
|
||||||
? async () => {
|
|
||||||
try {
|
|
||||||
await onCancel()
|
|
||||||
canceled = true
|
|
||||||
toaster.warning(`${t} canceled`, { ...toastProps, flowId })
|
|
||||||
} catch (err) {
|
|
||||||
toaster.danger(`failed to cancel ${t}`, { ...toastProps, flowId })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onUndo: onUndo
|
|
||||||
? async () => {
|
|
||||||
try {
|
|
||||||
await onUndo()
|
|
||||||
canceled = true
|
|
||||||
} catch (err) {
|
|
||||||
toaster.danger(`failed to undo ${t}`, { ...toastProps, flowId })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
flowId,
|
|
||||||
...toastProps
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
const ret = await onPending()
|
|
||||||
if (!canceled) {
|
|
||||||
if (hideSuccess) {
|
|
||||||
toaster.endFlow(flowId)
|
|
||||||
} else {
|
|
||||||
toaster.success(`${t} successful`, { ...toastProps, flowId })
|
|
||||||
}
|
|
||||||
await onSuccess?.()
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
} catch (err) {
|
|
||||||
// ignore errors if canceled since they might be caused by cancellation
|
|
||||||
if (canceled) return
|
|
||||||
const reason = err?.message?.toString().toLowerCase() || 'unknown reason'
|
|
||||||
if (hideError) {
|
|
||||||
toaster.endFlow(flowId)
|
|
||||||
} else {
|
|
||||||
toaster.danger(`${t} failed: ${reason}`, { ...toastProps, flowId })
|
|
||||||
}
|
|
||||||
await onError?.()
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return wrapper
|
|
||||||
}
|
|
||||||
|
@ -21,20 +21,6 @@
|
|||||||
border-color: var(--bs-warning-border-subtle);
|
border-color: var(--bs-warning-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toastUndo {
|
|
||||||
font-style: normal;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toastCancel {
|
|
||||||
font-style: italic;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toastClose {
|
.toastClose {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: "lightning";
|
font-family: "lightning";
|
||||||
|
@ -2,7 +2,7 @@ import UpBolt from '@/svgs/bolt.svg'
|
|||||||
import styles from './upvote.module.css'
|
import styles from './upvote.module.css'
|
||||||
import { gql, useMutation } from '@apollo/client'
|
import { gql, useMutation } from '@apollo/client'
|
||||||
import ActionTooltip from './action-tooltip'
|
import ActionTooltip from './action-tooltip'
|
||||||
import ItemAct, { useAct, useZap } from './item-act'
|
import ItemAct, { ZapUndoController, useZap } from './item-act'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import getColor from '@/lib/rainbow'
|
import getColor from '@/lib/rainbow'
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
@ -59,7 +59,7 @@ export function DropdownItemUpVote ({ item }) {
|
|||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
showModal(onClose =>
|
showModal(onClose =>
|
||||||
<ItemAct onClose={onClose} itemId={item.id} />)
|
<ItemAct onClose={onClose} item={item} />)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className='text-success'>zap</span>
|
<span className='text-success'>zap</span>
|
||||||
@ -97,6 +97,9 @@ export default function UpVote ({ item, className }) {
|
|||||||
}`
|
}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [controller, setController] = useState(null)
|
||||||
|
const pending = controller?.started && !controller.done
|
||||||
|
|
||||||
const setVoteShow = useCallback((yes) => {
|
const setVoteShow = useCallback((yes) => {
|
||||||
if (!me) return
|
if (!me) return
|
||||||
|
|
||||||
@ -125,7 +128,6 @@ export default function UpVote ({ item, className }) {
|
|||||||
}
|
}
|
||||||
}, [me, tipShow, setWalkthrough])
|
}, [me, tipShow, setWalkthrough])
|
||||||
|
|
||||||
const [act] = useAct()
|
|
||||||
const zap = useZap()
|
const zap = useZap()
|
||||||
|
|
||||||
const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt,
|
const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt,
|
||||||
@ -155,11 +157,19 @@ export default function UpVote ({ item, className }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTipShow(false)
|
setTipShow(false)
|
||||||
|
|
||||||
|
if (pending) {
|
||||||
|
controller.abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const c = new ZapUndoController()
|
||||||
|
setController(c)
|
||||||
|
|
||||||
showModal(onClose =>
|
showModal(onClose =>
|
||||||
<ItemAct onClose={onClose} itemId={item.id} />, { onClose: handleModalClosed })
|
<ItemAct onClose={onClose} item={item} abortSignal={c.signal} />, { onClose: handleModalClosed })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleShortPress = () => {
|
const handleShortPress = async () => {
|
||||||
if (me) {
|
if (me) {
|
||||||
if (!item) return
|
if (!item) return
|
||||||
|
|
||||||
@ -174,9 +184,16 @@ export default function UpVote ({ item, className }) {
|
|||||||
setTipShow(true)
|
setTipShow(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
zap({ item, me })
|
if (pending) {
|
||||||
|
controller.abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const c = new ZapUndoController()
|
||||||
|
setController(c)
|
||||||
|
|
||||||
|
await zap({ item, me, abortSignal: c.signal })
|
||||||
} else {
|
} else {
|
||||||
showModal(onClose => <ItemAct onClose={onClose} itemId={item.id} act={act} />, { onClose: handleModalClosed })
|
showModal(onClose => <ItemAct onClose={onClose} item={item} />, { onClose: handleModalClosed })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,7 +219,8 @@ export default function UpVote ({ item, className }) {
|
|||||||
`${styles.upvote}
|
`${styles.upvote}
|
||||||
${className || ''}
|
${className || ''}
|
||||||
${disabled ? styles.noSelfTips : ''}
|
${disabled ? styles.noSelfTips : ''}
|
||||||
${meSats ? styles.voted : ''}`
|
${meSats ? styles.voted : ''}
|
||||||
|
${pending ? styles.pending : ''}`
|
||||||
}
|
}
|
||||||
style={meSats || hover
|
style={meSats || hover
|
||||||
? {
|
? {
|
||||||
|
@ -34,4 +34,18 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 4px;
|
left: 4px;
|
||||||
width: 17px;
|
width: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pending {
|
||||||
|
animation-name: pulse;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-duration: 0.25s;
|
||||||
|
animation-direction: alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
fill: #a5a5a5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -29,6 +29,7 @@ import NostrIcon from '@/svgs/nostr.svg'
|
|||||||
import GithubIcon from '@/svgs/github-fill.svg'
|
import GithubIcon from '@/svgs/github-fill.svg'
|
||||||
import TwitterIcon from '@/svgs/twitter-fill.svg'
|
import TwitterIcon from '@/svgs/twitter-fill.svg'
|
||||||
import { UNKNOWN_LINK_REL, MEDIA_URL } from '@/lib/constants'
|
import { UNKNOWN_LINK_REL, MEDIA_URL } from '@/lib/constants'
|
||||||
|
import ItemPopover from './item-popover'
|
||||||
|
|
||||||
export default function UserHeader ({ user }) {
|
export default function UserHeader ({ user }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -284,7 +285,11 @@ function HeaderHeader ({ user }) {
|
|||||||
</Button>
|
</Button>
|
||||||
<div className='d-flex flex-column mt-1 ms-0'>
|
<div className='d-flex flex-column mt-1 ms-0'>
|
||||||
<small className='text-muted d-flex-inline'>stacking since: {user.since
|
<small className='text-muted d-flex-inline'>stacking since: {user.since
|
||||||
? <Link href={`/items/${user.since}`} className='ms-1'>#{user.since}</Link>
|
? (
|
||||||
|
<ItemPopover id={user.since}>
|
||||||
|
<Link href={`/items/${user.since}`} className='ms-1'>#{user.since}</Link>
|
||||||
|
</ItemPopover>
|
||||||
|
)
|
||||||
: <span>never</span>}
|
: <span>never</span>}
|
||||||
</small>
|
</small>
|
||||||
{user.optional.maxStreak !== null &&
|
{user.optional.maxStreak !== null &&
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
import { LNbitsProvider, useLNbits } from './lnbits'
|
import { LNbitsProvider, useLNbits } from './lnbits'
|
||||||
import { NWCProvider, useNWC } from './nwc'
|
import { NWCProvider, useNWC } from './nwc'
|
||||||
import { useToast, withToastFlow } from '@/components/toast'
|
|
||||||
import { gql, useMutation } from '@apollo/client'
|
|
||||||
import { LNCProvider, useLNC } from './lnc'
|
import { LNCProvider, useLNC } from './lnc'
|
||||||
|
|
||||||
const WebLNContext = createContext({})
|
const WebLNContext = createContext({})
|
||||||
@ -86,31 +84,6 @@ function RawWebLNProvider ({ children }) {
|
|||||||
// TODO: implement fallbacks via provider priority
|
// TODO: implement fallbacks via provider priority
|
||||||
const provider = enabledProviders[0]
|
const provider = enabledProviders[0]
|
||||||
|
|
||||||
const toaster = useToast()
|
|
||||||
const [cancelInvoice] = useMutation(gql`
|
|
||||||
mutation cancelInvoice($hash: String!, $hmac: String!) {
|
|
||||||
cancelInvoice(hash: $hash, hmac: $hmac) {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
|
|
||||||
const sendPaymentWithToast = withToastFlow(toaster)(
|
|
||||||
({ bolt11, hash, hmac, expiresAt, flowId }) => {
|
|
||||||
const expiresIn = (+new Date(expiresAt)) - (+new Date())
|
|
||||||
return {
|
|
||||||
flowId: flowId || hash,
|
|
||||||
type: 'payment',
|
|
||||||
onPending: async () => {
|
|
||||||
await provider.sendPayment(bolt11)
|
|
||||||
},
|
|
||||||
// hash and hmac are only passed for JIT invoices
|
|
||||||
onCancel: () => hash && hmac ? cancelInvoice({ variables: { hash, hmac } }) : undefined,
|
|
||||||
timeout: expiresIn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const setProvider = useCallback((defaultProvider) => {
|
const setProvider = useCallback((defaultProvider) => {
|
||||||
// move provider to the start to set it as default
|
// move provider to the start to set it as default
|
||||||
setEnabledProviders(providers => {
|
setEnabledProviders(providers => {
|
||||||
@ -129,8 +102,17 @@ function RawWebLNProvider ({ children }) {
|
|||||||
await lnc.clearConfig()
|
await lnc.clearConfig()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
provider: isEnabled(provider)
|
||||||
|
? { name: provider.name, sendPayment: provider.sendPayment }
|
||||||
|
: null,
|
||||||
|
enabledProviders,
|
||||||
|
setProvider,
|
||||||
|
clearConfig
|
||||||
|
}), [provider, enabledProviders, setProvider])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WebLNContext.Provider value={{ provider: isEnabled(provider) ? { name: provider.name, sendPayment: sendPaymentWithToast } : null, enabledProviders, setProvider, clearConfig }}>
|
<WebLNContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</WebLNContext.Provider>
|
</WebLNContext.Provider>
|
||||||
)
|
)
|
||||||
|
@ -9,6 +9,7 @@ import CancelButton from '../cancel-button'
|
|||||||
import { Mutex } from 'async-mutex'
|
import { Mutex } from 'async-mutex'
|
||||||
import { Wallet } from '@/lib/constants'
|
import { Wallet } from '@/lib/constants'
|
||||||
import { useMe } from '../me'
|
import { useMe } from '../me'
|
||||||
|
import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment'
|
||||||
|
|
||||||
const LNCContext = createContext()
|
const LNCContext = createContext()
|
||||||
const mutex = new Mutex()
|
const mutex = new Mutex()
|
||||||
@ -109,7 +110,14 @@ export function LNCProvider ({ children }) {
|
|||||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
||||||
return { preimage }
|
return { preimage }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
|
const msg = err.message || err.toString?.()
|
||||||
|
logger.error('payment failed:', `payment_hash=${hash}`, msg)
|
||||||
|
if (msg.includes('invoice expired')) {
|
||||||
|
throw new InvoiceExpiredError(hash)
|
||||||
|
}
|
||||||
|
if (msg.includes('canceled')) {
|
||||||
|
throw new InvoiceCanceledError(hash)
|
||||||
|
}
|
||||||
throw err
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
|
@ -6,8 +6,9 @@ import { parseNwcUrl } from '@/lib/url'
|
|||||||
import { useWalletLogger } from '../logger'
|
import { useWalletLogger } from '../logger'
|
||||||
import { Status, migrateLocalStorage } from '.'
|
import { Status, migrateLocalStorage } from '.'
|
||||||
import { bolt11Tags } from '@/lib/bolt11'
|
import { bolt11Tags } from '@/lib/bolt11'
|
||||||
import { Wallet } from '@/lib/constants'
|
import { JIT_INVOICE_TIMEOUT_MS, Wallet } from '@/lib/constants'
|
||||||
import { useMe } from '../me'
|
import { useMe } from '../me'
|
||||||
|
import { InvoiceExpiredError } from '../payment'
|
||||||
|
|
||||||
const NWCContext = createContext()
|
const NWCContext = createContext()
|
||||||
|
|
||||||
@ -205,11 +206,11 @@ export function NWCProvider ({ children }) {
|
|||||||
(async function () {
|
(async function () {
|
||||||
// timeout since NWC is async (user needs to confirm payment in wallet)
|
// timeout since NWC is async (user needs to confirm payment in wallet)
|
||||||
// timeout is same as invoice expiry
|
// timeout is same as invoice expiry
|
||||||
const timeout = 180_000
|
const timeout = JIT_INVOICE_TIMEOUT_MS
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const msg = 'timeout waiting for info event'
|
const msg = 'timeout waiting for payment'
|
||||||
logger.error(msg)
|
logger.error(msg)
|
||||||
reject(new Error(msg))
|
reject(new InvoiceExpiredError(hash))
|
||||||
sub?.close()
|
sub?.close()
|
||||||
}, timeout)
|
}, timeout)
|
||||||
|
|
||||||
|
@ -255,3 +255,17 @@ function getClient (uri) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function optimisticUpdate ({ mutation, variables, optimisticResponse, update }) {
|
||||||
|
const { cache, queryManager } = getApolloClient()
|
||||||
|
|
||||||
|
const mutationId = String(queryManager.mutationIdCounter++)
|
||||||
|
queryManager.markMutationOptimistic(optimisticResponse, {
|
||||||
|
mutationId,
|
||||||
|
document: mutation,
|
||||||
|
variables,
|
||||||
|
update
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => cache.removeOptimistic(mutationId)
|
||||||
|
}
|
||||||
|
@ -126,6 +126,7 @@ export const ITEM_ALLOW_EDITS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const INVOICE_RETENTION_DAYS = 7
|
export const INVOICE_RETENTION_DAYS = 7
|
||||||
|
export const JIT_INVOICE_TIMEOUT_MS = 180_000
|
||||||
|
|
||||||
export const FAST_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL)
|
export const FAST_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL)
|
||||||
export const NORMAL_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL)
|
export const NORMAL_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL)
|
||||||
@ -148,3 +149,5 @@ export const getWalletBy = (key, value) => {
|
|||||||
}
|
}
|
||||||
throw new Error(`wallet not found: ${key}=${value}`)
|
throw new Error(`wallet not found: ${key}=${value}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ZAP_UNDO_DELAY_MS = 5_000
|
||||||
|
39
lib/url.js
39
lib/url.js
@ -52,6 +52,45 @@ export function parseInternalLinks (href) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseEmbedUrl (href) {
|
||||||
|
const { hostname, pathname, searchParams } = new URL(href)
|
||||||
|
|
||||||
|
if (hostname.endsWith('youtube.com') && pathname.includes('/watch')) {
|
||||||
|
return {
|
||||||
|
provider: 'youtube',
|
||||||
|
id: searchParams.get('v'),
|
||||||
|
meta: {
|
||||||
|
href,
|
||||||
|
start: searchParams.get('t')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostname.endsWith('youtu.be') && pathname.length > 1) {
|
||||||
|
return {
|
||||||
|
provider: 'youtube',
|
||||||
|
id: pathname.slice(1), // remove leading slash
|
||||||
|
meta: {
|
||||||
|
href,
|
||||||
|
start: searchParams.get('t')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostname.endsWith('rumble.com') && pathname.includes('/embed')) {
|
||||||
|
return {
|
||||||
|
provider: 'rumble',
|
||||||
|
id: null, // not required
|
||||||
|
meta: {
|
||||||
|
href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Important to return empty object as default
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
export function stripTrailingSlash (uri) {
|
export function stripTrailingSlash (uri) {
|
||||||
return uri.endsWith('/') ? uri.slice(0, -1) : uri
|
return uri.endsWith('/') ? uri.slice(0, -1) : uri
|
||||||
}
|
}
|
||||||
|
@ -42,7 +42,7 @@ export function middleware (request) {
|
|||||||
// unsafe-inline for styles is not ideal but okay if script-src is using nonces
|
// unsafe-inline for styles is not ideal but okay if script-src is using nonces
|
||||||
"style-src 'self' a.stacker.news 'unsafe-inline'",
|
"style-src 'self' a.stacker.news 'unsafe-inline'",
|
||||||
"manifest-src 'self'",
|
"manifest-src 'self'",
|
||||||
'frame-src www.youtube.com platform.twitter.com',
|
'frame-src www.youtube.com platform.twitter.com rumble.com',
|
||||||
"connect-src 'self' https: wss:" + devSrc,
|
"connect-src 'self' https: wss:" + devSrc,
|
||||||
// disable dangerous plugins like Flash
|
// disable dangerous plugins like Flash
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
|
@ -21,6 +21,7 @@ import { ChainFeeProvider } from '@/components/chain-fee.js'
|
|||||||
import { WebLNProvider } from '@/components/webln'
|
import { WebLNProvider } from '@/components/webln'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
|
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
|
||||||
|
import { ClientNotificationProvider } from '@/components/client-notifications'
|
||||||
|
|
||||||
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
|
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
|
||||||
|
|
||||||
@ -104,28 +105,30 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
|||||||
<ApolloProvider client={client}>
|
<ApolloProvider client={client}>
|
||||||
<MeProvider me={me}>
|
<MeProvider me={me}>
|
||||||
<HasNewNotesProvider>
|
<HasNewNotesProvider>
|
||||||
<LoggerProvider>
|
<ClientNotificationProvider>
|
||||||
<ServiceWorkerProvider>
|
<LoggerProvider>
|
||||||
<PriceProvider price={price}>
|
<ServiceWorkerProvider>
|
||||||
<LightningProvider>
|
<PriceProvider price={price}>
|
||||||
<ToastProvider>
|
<LightningProvider>
|
||||||
<WebLNProvider>
|
<ToastProvider>
|
||||||
<ShowModalProvider>
|
<WebLNProvider>
|
||||||
<BlockHeightProvider blockHeight={blockHeight}>
|
<ShowModalProvider>
|
||||||
<ChainFeeProvider chainFee={chainFee}>
|
<BlockHeightProvider blockHeight={blockHeight}>
|
||||||
<ErrorBoundary>
|
<ChainFeeProvider chainFee={chainFee}>
|
||||||
<Component ssrData={ssrData} {...otherProps} />
|
<ErrorBoundary>
|
||||||
{!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />}
|
<Component ssrData={ssrData} {...otherProps} />
|
||||||
</ErrorBoundary>
|
{!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />}
|
||||||
</ChainFeeProvider>
|
</ErrorBoundary>
|
||||||
</BlockHeightProvider>
|
</ChainFeeProvider>
|
||||||
</ShowModalProvider>
|
</BlockHeightProvider>
|
||||||
</WebLNProvider>
|
</ShowModalProvider>
|
||||||
</ToastProvider>
|
</WebLNProvider>
|
||||||
</LightningProvider>
|
</ToastProvider>
|
||||||
</PriceProvider>
|
</LightningProvider>
|
||||||
</ServiceWorkerProvider>
|
</PriceProvider>
|
||||||
</LoggerProvider>
|
</ServiceWorkerProvider>
|
||||||
|
</LoggerProvider>
|
||||||
|
</ClientNotificationProvider>
|
||||||
</HasNewNotesProvider>
|
</HasNewNotesProvider>
|
||||||
</MeProvider>
|
</MeProvider>
|
||||||
</ApolloProvider>
|
</ApolloProvider>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import { Invoice } from '@/components/invoice'
|
import Invoice from '@/components/invoice'
|
||||||
import { QrSkeleton } from '@/components/qr'
|
import { QrSkeleton } from '@/components/qr'
|
||||||
import { CenterLayout } from '@/components/layout'
|
import { CenterLayout } from '@/components/layout'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
@ -10,6 +10,7 @@ import { getGetServerSideProps } from '@/api/ssrApollo'
|
|||||||
// force SSR to include CSP nonces
|
// force SSR to include CSP nonces
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: null })
|
export const getServerSideProps = getGetServerSideProps({ query: null })
|
||||||
|
|
||||||
|
// TODO: we can probably replace this component with <Invoice poll>
|
||||||
export default function FullInvoice () {
|
export default function FullInvoice () {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { data, error } = useQuery(INVOICE, SSR
|
const { data, error } = useQuery(INVOICE, SSR
|
||||||
|
@ -174,7 +174,7 @@ export function DonateButton () {
|
|||||||
amount: 10000
|
amount: 10000
|
||||||
}}
|
}}
|
||||||
schema={amountSchema}
|
schema={amountSchema}
|
||||||
invoiceable
|
prepaid
|
||||||
onSubmit={async ({ amount, hash, hmac }) => {
|
onSubmit={async ({ amount, hash, hmac }) => {
|
||||||
const { error } = await donateToRewards({
|
const { error } = await donateToRewards({
|
||||||
variables: {
|
variables: {
|
||||||
|
@ -26,7 +26,7 @@ import { NostrAuth } from '@/components/nostr-auth'
|
|||||||
import { useToast } from '@/components/toast'
|
import { useToast } from '@/components/toast'
|
||||||
import { useServiceWorkerLogger } from '@/components/logger'
|
import { useServiceWorkerLogger } from '@/components/logger'
|
||||||
import { useMe } from '@/components/me'
|
import { useMe } from '@/components/me'
|
||||||
import { INVOICE_RETENTION_DAYS } from '@/lib/constants'
|
import { INVOICE_RETENTION_DAYS, ZAP_UNDO_DELAY_MS } from '@/lib/constants'
|
||||||
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
||||||
import DeleteIcon from '@/svgs/delete-bin-line.svg'
|
import DeleteIcon from '@/svgs/delete-bin-line.svg'
|
||||||
import { useField } from 'formik'
|
import { useField } from 'formik'
|
||||||
@ -1007,7 +1007,7 @@ const ZapUndosField = () => {
|
|||||||
<Info>
|
<Info>
|
||||||
<ul className='fw-bold'>
|
<ul className='fw-bold'>
|
||||||
<li>An undo button is shown after every zap that exceeds or is equal to the threshold</li>
|
<li>An undo button is shown after every zap that exceeds or is equal to the threshold</li>
|
||||||
<li>The button is shown for 5 seconds</li>
|
<li>The button is shown for {ZAP_UNDO_DELAY_MS / 1000} seconds</li>
|
||||||
<li>The button is only shown for zaps from the custodial wallet</li>
|
<li>The button is only shown for zaps from the custodial wallet</li>
|
||||||
<li>Use a budget or manual approval with attached wallets</li>
|
<li>Use a budget or manual approval with attached wallets</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
3
sndev
3
sndev
@ -400,7 +400,8 @@ __sndev__pr_track() {
|
|||||||
|
|
||||||
ref=$(echo "$json" | grep -e '"ref"' | head -n1 | sed -e 's/^.*"ref":[[:space:]]*"//; s/",[[:space:]]*$//')
|
ref=$(echo "$json" | grep -e '"ref"' | head -n1 | sed -e 's/^.*"ref":[[:space:]]*"//; s/",[[:space:]]*$//')
|
||||||
git fetch "$remote" "$ref"
|
git fetch "$remote" "$ref"
|
||||||
git checkout -b "pr/$1" "$remote/$ref"
|
git checkout -t -b "pr/$1" "$remote/$ref"
|
||||||
|
git config --local "remote.$remote.push" pr/$1:$ref
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,7 +15,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.youtubeContainer {
|
.videoWrapper {
|
||||||
|
max-width: 640px;
|
||||||
|
padding-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoContainer {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 0;
|
height: 0;
|
||||||
@ -23,7 +28,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.youtubeContainer iframe {
|
.videoContainer iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -36,16 +41,11 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.youtubeContainerContainer {
|
|
||||||
max-width: 640px;
|
|
||||||
padding-right: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.twitterContainer:not(:first-child) {
|
.twitterContainer:not(:first-child) {
|
||||||
margin-top: .75rem;
|
margin-top: .75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.youtubeContainerContainer:not(:first-child) {
|
.videoWrapper:not(:first-child) {
|
||||||
margin-top: .75rem;
|
margin-top: .75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user