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')
|
||||
AND "itemId" = ${Number(id)}::INTEGER
|
||||
AND "userId" = ${me.id}::INTEGER)::INTEGER)`,
|
||||
{ models }
|
||||
{ models, lnd, hash, hmac }
|
||||
)
|
||||
} else {
|
||||
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
|
||||
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
|
||||
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 })
|
||||
}}
|
||||
schema={schema}
|
||||
invoiceable={{ requireSession: true }}
|
||||
requireSession
|
||||
prepaid
|
||||
onSubmit={
|
||||
handleSubmit ||
|
||||
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
|
||||
? <Skull className={styles.dontLike} width={24} height={24} />
|
||||
: 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} />}
|
||||
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
||||
<div className='d-flex align-items-center'>
|
||||
|
@ -96,7 +96,7 @@ export function DiscussionForm ({
|
||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||
}}
|
||||
schema={schema}
|
||||
invoiceable
|
||||
prepaid
|
||||
onSubmit={handleSubmit || onSubmit}
|
||||
storageKeyPrefix={storageKeyPrefix}
|
||||
>
|
||||
|
@ -1,15 +1,15 @@
|
||||
import Dropdown from 'react-bootstrap/Dropdown'
|
||||
import { useShowModal } from './modal'
|
||||
import { useToast } from './toast'
|
||||
import ItemAct, { zapUndosThresholdReached } from './item-act'
|
||||
import ItemAct from './item-act'
|
||||
import AccordianItem from './accordian-item'
|
||||
import Flag from '@/svgs/flag-fill.svg'
|
||||
import { useMemo } from 'react'
|
||||
import getColor from '@/lib/rainbow'
|
||||
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
|
||||
? {
|
||||
fill: getColor(meDontLikeSats),
|
||||
@ -17,14 +17,13 @@ export function DownZap ({ id, meDontLikeSats, ...props }) {
|
||||
}
|
||||
: undefined), [meDontLikeSats])
|
||||
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 showModal = useShowModal()
|
||||
const me = useMe()
|
||||
|
||||
return (
|
||||
<As
|
||||
@ -32,12 +31,7 @@ function DownZapper ({ id, As, children }) {
|
||||
try {
|
||||
showModal(onClose =>
|
||||
<ItemAct
|
||||
onClose={(amount) => {
|
||||
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
|
||||
onClose={onClose} item={item} down
|
||||
>
|
||||
<AccordianItem
|
||||
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 (
|
||||
<DownZapper
|
||||
As={Dropdown.Item}
|
||||
id={id}
|
||||
item={item}
|
||||
>
|
||||
<span className='text-danger'>downzap</span>
|
||||
</DownZapper>
|
||||
|
@ -64,6 +64,7 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
|
||||
export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) {
|
||||
const [lineItems, setLineItems] = useState({})
|
||||
const [disabled, setDisabled] = useState(false)
|
||||
const me = useMe()
|
||||
|
||||
const remoteLineItems = useRemoteLineItems()
|
||||
|
||||
@ -76,14 +77,18 @@ export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = ()
|
||||
|
||||
const value = useMemo(() => {
|
||||
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 {
|
||||
lines,
|
||||
merge: mergeLineItems,
|
||||
total: Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0),
|
||||
total,
|
||||
disabled,
|
||||
setDisabled
|
||||
setDisabled,
|
||||
free
|
||||
}
|
||||
}, [baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled])
|
||||
}, [me?.privates?.sats, baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled])
|
||||
|
||||
return (
|
||||
<FeeButtonContext.Provider value={value}>
|
||||
@ -111,9 +116,7 @@ function FreebieDialog () {
|
||||
|
||||
export default function FeeButton ({ ChildButton = SubmitButton, variant, text, disabled }) {
|
||||
const me = useMe()
|
||||
const { lines, total, disabled: ctxDisabled } = 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 { lines, total, disabled: ctxDisabled, free } = useFeeButton()
|
||||
const feeText = free
|
||||
? 'free'
|
||||
: total > 1
|
||||
|
@ -18,7 +18,6 @@ import { gql, useLazyQuery } from '@apollo/client'
|
||||
import { USER_SUGGESTIONS } from '@/fragments/users'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import { useToast } from './toast'
|
||||
import { useInvoiceable } from './invoice'
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
import textAreaCaret from 'textarea-caret'
|
||||
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 EyeClose from '@/svgs/eye-close-line.svg'
|
||||
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 ({
|
||||
children, variant, value, onClick, disabled, nonDisabledText, ...props
|
||||
@ -793,11 +804,16 @@ const StorageKeyPrefixContext = createContext()
|
||||
|
||||
export function Form ({
|
||||
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 initialErrorToasted = useRef(false)
|
||||
const feeButton = useFeeButton()
|
||||
const payment = usePayment()
|
||||
const me = useMe()
|
||||
const { notify, unnotify } = useClientNotifications()
|
||||
|
||||
useEffect(() => {
|
||||
if (initialError && !initialErrorToasted.current) {
|
||||
toaster.danger('form error: ' + initialError.message || initialError.toString?.())
|
||||
@ -820,37 +836,57 @@ export function Form ({
|
||||
})
|
||||
}, [storageKeyPrefix])
|
||||
|
||||
// if `invoiceable` is set,
|
||||
// support for payment per invoice if they are lurking or don't have enough balance
|
||||
// is added to submit handlers.
|
||||
// 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) => {
|
||||
const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => {
|
||||
const variables = { amount, ...values }
|
||||
let revert, cancel, nid
|
||||
try {
|
||||
if (onSubmit) {
|
||||
// extract cost from formik fields
|
||||
// (cost may also be set in a formik field named 'amount')
|
||||
const cost = feeButton?.total || values?.amount
|
||||
if (cost) {
|
||||
values.cost = cost
|
||||
if (requireSession && !me) {
|
||||
throw new SessionRequiredError()
|
||||
}
|
||||
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
|
||||
clearLocalStorage(values)
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err.message || err.toString?.()
|
||||
// ignore errors from JIT invoices or payments from attached wallets
|
||||
// that mean that submit failed because user aborted the payment
|
||||
if (msg === 'modal closed' || msg === 'invoice canceled') return
|
||||
toaster.danger('submit error: ' + msg)
|
||||
revert?.()
|
||||
|
||||
if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) {
|
||||
return
|
||||
}
|
||||
|
||||
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 (
|
||||
<Formik
|
||||
|
@ -1,23 +1,35 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useApolloClient, useMutation, useQuery } from '@apollo/client'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { gql } from 'graphql-tag'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
import AccordianItem from './accordian-item'
|
||||
import Qr, { QrSkeleton } from './qr'
|
||||
import { INVOICE } from '@/fragments/wallet'
|
||||
import InvoiceStatus from './invoice-status'
|
||||
import { useMe } from './me'
|
||||
import { useShowModal } from './modal'
|
||||
import Qr from './qr'
|
||||
import Countdown from './countdown'
|
||||
import PayerData from './payer-data'
|
||||
import Bolt11Info from './bolt11-info'
|
||||
import { useWebLN } from './webln'
|
||||
import { FAST_POLL_INTERVAL } from '@/lib/constants'
|
||||
import { useQuery } from '@apollo/client'
|
||||
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 { 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 === undefined) webLn = true
|
||||
|
||||
@ -48,6 +60,11 @@ export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }
|
||||
|
||||
return (
|
||||
<>
|
||||
{webLnError && !(webLnError instanceof WebLnNotEnabledError) &&
|
||||
<div className='text-center text-danger mb-3'>
|
||||
Payment from attached wallet failed:
|
||||
<div>{webLnError.toString()}</div>
|
||||
</div>}
|
||||
<Qr
|
||||
webLn={webLn} value={invoice.bolt11}
|
||||
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 UpBolt from '@/svgs/bolt.svg'
|
||||
import { amountSchema } from '@/lib/validate'
|
||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||
import { payOrLoginError, useInvoiceModal } from './invoice'
|
||||
import { TOAST_DEFAULT_DELAY_MS, useToast, withToastFlow } from './toast'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import { useToast } from './toast'
|
||||
import { useLightning } from './lightning'
|
||||
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 = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b)
|
||||
@ -41,222 +44,149 @@ const addCustomTip = (amount) => {
|
||||
window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
|
||||
}
|
||||
|
||||
export const zapUndosThresholdReached = (me, amount) => {
|
||||
if (!me) return false
|
||||
const enabled = me.privates.zapUndos !== null
|
||||
return enabled ? amount >= me.privates.zapUndos : false
|
||||
const setItemMeAnonSats = ({ id, amount }) => {
|
||||
const storageKey = `TIP-item:${id}`
|
||||
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
|
||||
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 me = useMe()
|
||||
const [oValue, setOValue] = useState()
|
||||
const strike = useLightning()
|
||||
const toaster = useToast()
|
||||
const client = useApolloClient()
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [onClose, itemId])
|
||||
}, [onClose, item.id])
|
||||
|
||||
const [act, actUpdate] = useAct()
|
||||
const act = useAct()
|
||||
|
||||
const onSubmit = useCallback(async ({ amount, hash, hmac }, { update, keepOpen }) => {
|
||||
if (!me) {
|
||||
const storageKey = `TIP-item:${itemId}`
|
||||
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
|
||||
window.localStorage.setItem(storageKey, existingAmount + amount)
|
||||
}
|
||||
const onSubmit = useCallback(async ({ amount, hash, hmac }) => {
|
||||
await act({
|
||||
variables: {
|
||||
id: itemId,
|
||||
id: item.id,
|
||||
sats: Number(amount),
|
||||
act: down ? 'DONT_LIKE_THIS' : 'TIP',
|
||||
hash,
|
||||
hmac
|
||||
},
|
||||
update
|
||||
}
|
||||
})
|
||||
// only strike when zap undos not enabled
|
||||
// due to optimistic UX on zap undos
|
||||
if (!zapUndosThresholdReached(me, Number(amount))) await strike()
|
||||
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
||||
addCustomTip(Number(amount))
|
||||
if (!keepOpen) onClose(Number(amount))
|
||||
}, [me, act, down, itemId, strike])
|
||||
}, [me, act, down, item.id, strike])
|
||||
|
||||
const onSubmitWithUndos = withToastFlow(toaster)(
|
||||
(values, args) => {
|
||||
const { flowId } = args
|
||||
let canceled
|
||||
const sats = values.amount
|
||||
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 optimisticUpdate = useCallback(({ amount }) => {
|
||||
const variables = {
|
||||
id: item.id,
|
||||
sats: Number(amount),
|
||||
act: down ? 'DONT_LIKE_THIS' : 'TIP'
|
||||
}
|
||||
)
|
||||
const optimisticResponse = { act: { ...variables, path: item.path } }
|
||||
strike()
|
||||
onClose()
|
||||
return { mutation: ACT_MUTATION, variables, optimisticResponse, update: actUpdate({ me }) }
|
||||
}, [item.id, down, !!me, strike])
|
||||
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
amount: me?.privates?.tipDefault || defaultTips[0],
|
||||
default: false
|
||||
}}
|
||||
schema={amountSchema}
|
||||
invoiceable
|
||||
onSubmit={(values, ...args) => {
|
||||
if (zapUndosThresholdReached(me, values.amount)) {
|
||||
return onSubmitWithUndos(values, ...args)
|
||||
}
|
||||
return onSubmit(values, ...args)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='amount'
|
||||
name='amount'
|
||||
type='number'
|
||||
innerRef={inputRef}
|
||||
overrideValue={oValue}
|
||||
required
|
||||
autoFocus
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<div>
|
||||
<Tips setOValue={setOValue} />
|
||||
</div>
|
||||
{children}
|
||||
<div className='d-flex mt-3'>
|
||||
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
<ClientNotifyProvider additionalProps={{ itemId: item.id }}>
|
||||
<Form
|
||||
initial={{
|
||||
amount: me?.privates?.tipDefault || defaultTips[0],
|
||||
default: false
|
||||
}}
|
||||
schema={amountSchema}
|
||||
prepaid
|
||||
optimisticUpdate={optimisticUpdate}
|
||||
onSubmit={onSubmit}
|
||||
clientNotification={ClientNotification.Zap}
|
||||
signal={abortSignal}
|
||||
>
|
||||
<Input
|
||||
label='amount'
|
||||
name='amount'
|
||||
type='number'
|
||||
innerRef={inputRef}
|
||||
overrideValue={oValue}
|
||||
required
|
||||
autoFocus
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<div>
|
||||
<Tips setOValue={setOValue} />
|
||||
</div>
|
||||
{children}
|
||||
<div className='d-flex mt-3'>
|
||||
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
</ClientNotifyProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAct ({ onUpdate } = {}) {
|
||||
const me = useMe()
|
||||
|
||||
const update = useCallback((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: 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)
|
||||
export const ACT_MUTATION = gql`
|
||||
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) {
|
||||
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
|
||||
id
|
||||
sats
|
||||
path
|
||||
act
|
||||
}
|
||||
}, [!!me, onUpdate])
|
||||
}`
|
||||
|
||||
const [act] = useMutation(
|
||||
gql`
|
||||
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) {
|
||||
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
|
||||
id
|
||||
sats
|
||||
path
|
||||
act
|
||||
}
|
||||
}`, { update }
|
||||
)
|
||||
return [act, update]
|
||||
export function useAct ({ onUpdate } = {}) {
|
||||
const [act] = useMutation(ACT_MUTATION)
|
||||
return act
|
||||
}
|
||||
|
||||
export function useZap () {
|
||||
@ -307,118 +237,107 @@ export function useZap () {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [zap] = useMutation(
|
||||
gql`
|
||||
mutation idempotentAct($id: ID!, $sats: Int!) {
|
||||
act(id: $id, sats: $sats, idempotent: true) {
|
||||
id
|
||||
sats
|
||||
path
|
||||
}
|
||||
}`
|
||||
)
|
||||
const ZAP_MUTATION = gql`
|
||||
mutation idempotentAct($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
|
||||
act(id: $id, sats: $sats, hash: $hash, hmac: $hmac, idempotent: true) {
|
||||
id
|
||||
sats
|
||||
path
|
||||
}
|
||||
}`
|
||||
const [zap] = useMutation(ZAP_MUTATION)
|
||||
const me = useMe()
|
||||
const { notify, unnotify } = useClientNotifications()
|
||||
|
||||
const toaster = useToast()
|
||||
const strike = useLightning()
|
||||
const [act] = useAct()
|
||||
const client = useApolloClient()
|
||||
const payment = usePayment()
|
||||
|
||||
const invoiceableAct = useInvoiceModal(
|
||||
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 }) => {
|
||||
return useCallback(async ({ item, mem, abortSignal }) => {
|
||||
const meSats = (item?.meSats || 0)
|
||||
|
||||
// add current sats to next tip since idempotent zaps use desired total zap not difference
|
||||
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 insufficientFunds = me?.privates.sats < amount
|
||||
const variables = { id: item.id, sats, act: 'TIP' }
|
||||
const notifyProps = { itemId: item.id, sats: satsDelta }
|
||||
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 {
|
||||
if (insufficientFunds) throw new Error('insufficient funds')
|
||||
revert = optimisticUpdate({ mutation: ZAP_MUTATION, variables, optimisticResponse, update })
|
||||
strike()
|
||||
if (zapUndosThresholdReached(me, amount)) {
|
||||
await zapWithUndos(zapArgs)
|
||||
} else {
|
||||
await zap(zapArgs)
|
||||
|
||||
await abortSignal.pause({ me, amount: satsDelta })
|
||||
|
||||
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) {
|
||||
if (payOrLoginError(error)) {
|
||||
// call non-idempotent version
|
||||
const amount = sats - meSats
|
||||
optimisticResponse.act.amount = amount
|
||||
try {
|
||||
await invoiceableAct({ amount }, {
|
||||
variables: { ...variables, sats: amount },
|
||||
optimisticResponse,
|
||||
update,
|
||||
flowId
|
||||
})
|
||||
} catch (error) {}
|
||||
revert?.()
|
||||
|
||||
if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) {
|
||||
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 Link from 'next/link'
|
||||
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 { useQuoteReply } from './use-quote-reply'
|
||||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||
@ -70,6 +70,7 @@ function ItemEmbed ({ item }) {
|
||||
const [overflowing, setOverflowing] = useState(false)
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
// This Twitter embed could use similar logic to the video embeds below
|
||||
const twitter = item.url?.match(/^https?:\/\/(?:twitter|x)\.com\/(?:#!\/)?\w+\/status(?:es)?\/(?<id>\d+)/)
|
||||
if (twitter?.groups?.id) {
|
||||
return (
|
||||
@ -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)
|
||||
if (youtube?.groups?.id) {
|
||||
const { provider, id, meta } = parseEmbedUrl(item.url)
|
||||
|
||||
if (provider === 'youtube') {
|
||||
return (
|
||||
<div className={styles.youtubeContainerContainer}>
|
||||
<div className={styles.videoWrapper}>
|
||||
<YouTube
|
||||
videoId={youtube.groups.id} className={styles.youtubeContainer} opts={{
|
||||
videoId={id} className={styles.videoContainer} opts={{
|
||||
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)) {
|
||||
return <ZoomableImage src={item.url} rel={item.rel ?? UNKNOWN_LINK_REL} />
|
||||
}
|
||||
|
@ -176,7 +176,7 @@ export default function ItemInfo ({
|
||||
!item.mine && !item.deletedAt &&
|
||||
(item.meDontLikeSats > meTotalSats
|
||||
? <DropdownItemUpVote item={item} />
|
||||
: <DontLikeThisDropdownItem id={item.id} />)}
|
||||
: <DontLikeThisDropdownItem item={item} />)}
|
||||
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
|
||||
<>
|
||||
<hr className='dropdown-divider' />
|
||||
|
@ -63,7 +63,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
|
||||
{item.position && (pinnable || !item.subName)
|
||||
? <Pin width={24} height={24} className={styles.pin} />
|
||||
: 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
|
||||
? <AdIcon width={24} height={24} className={styles.ad} />
|
||||
: <UpVote item={item} className={styles.upvote} />}
|
||||
|
@ -105,7 +105,8 @@ export default function JobForm ({ item, sub }) {
|
||||
}}
|
||||
schema={jobSchema}
|
||||
storageKeyPrefix={storageKeyPrefix}
|
||||
invoiceable={{ requireSession: true }}
|
||||
requireSession
|
||||
prepaid
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<div className='form-group'>
|
||||
|
@ -143,7 +143,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||
}}
|
||||
schema={schema}
|
||||
invoiceable
|
||||
prepaid
|
||||
onSubmit={onSubmit}
|
||||
storageKeyPrefix={storageKeyPrefix}
|
||||
>
|
||||
|
@ -30,12 +30,19 @@ import { nextBillingWithGrace } from '@/lib/territory'
|
||||
import { commentSubTreeRootId } from '@/lib/item'
|
||||
import LinkToContext from './link-to-context'
|
||||
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 }) {
|
||||
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 (
|
||||
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
|
||||
<NotificationLayout nid={nid(n)} {...defaultOnClick(itemN)} fresh={fresh}>
|
||||
{
|
||||
(type === 'Earn' && <EarnNotification n={n} />) ||
|
||||
(type === 'Revenue' && <RevenueNotification n={n} />) ||
|
||||
@ -53,7 +60,11 @@ function Notification ({ n, fresh }) {
|
||||
(type === 'FollowActivity' && <FollowActivity n={n} />) ||
|
||||
(type === 'TerritoryPost' && <TerritoryPost 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>
|
||||
)
|
||||
@ -102,6 +113,8 @@ const defaultOnClick = n => {
|
||||
if (type === 'Streak') return {}
|
||||
if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }
|
||||
|
||||
if (!n.item) return {}
|
||||
|
||||
// Votification, Mention, JobChanged, Reply all have item
|
||||
if (!n.item.title) {
|
||||
const rootId = commentSubTreeRootId(n.item)
|
||||
@ -534,6 +547,7 @@ export default function Notifications ({ ssrData }) {
|
||||
const { data, fetchMore } = useQuery(NOTIFICATIONS)
|
||||
const router = useRouter()
|
||||
const dat = useData(data, ssrData)
|
||||
const { notifications: clientNotifications } = useClientNotifications()
|
||||
|
||||
const { notifications, lastChecked, cursor } = useMemo(() => {
|
||||
if (!dat?.notifications) return {}
|
||||
@ -561,9 +575,12 @@ export default function Notifications ({ ssrData }) {
|
||||
|
||||
if (!dat) return <CommentsFlatSkeleton />
|
||||
|
||||
const sorted = [...clientNotifications, ...notifications]
|
||||
.sort((a, b) => new Date(b.sortTime).getTime() - new Date(a.sortTime).getTime())
|
||||
|
||||
return (
|
||||
<>
|
||||
{notifications.map(n =>
|
||||
{sorted.map(n =>
|
||||
<Notification
|
||||
n={n} key={nid(n)}
|
||||
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 { useShowModal } from './modal'
|
||||
import { useRoot } from './root'
|
||||
import { payOrLoginError, useInvoiceModal } from './invoice'
|
||||
import { useAct } from './item-act'
|
||||
import { useAct, actUpdate, ACT_MUTATION } 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 }) {
|
||||
const me = useMe()
|
||||
const showModal = useShowModal()
|
||||
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
|
||||
const root = path.split('.')[0]
|
||||
cache.modify({
|
||||
@ -25,30 +33,55 @@ export default function PayBounty ({ children, item }) {
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
strike()
|
||||
onComplete()
|
||||
}, [strike])
|
||||
|
||||
const [act] = useAct({ onUpdate })
|
||||
|
||||
const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => {
|
||||
await act({ variables: { ...variables, hash, hmac } })
|
||||
}, [act])
|
||||
const act = useAct()
|
||||
|
||||
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 {
|
||||
await act({
|
||||
revert = optimisticUpdate({
|
||||
mutation: ACT_MUTATION,
|
||||
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: {
|
||||
act: variables
|
||||
}
|
||||
})
|
||||
onComplete()
|
||||
} catch (error) {
|
||||
if (payOrLoginError(error)) {
|
||||
showInvoiceModal({ amount: root.bounty }, { variables })
|
||||
revert?.()
|
||||
|
||||
if (error instanceof InvoiceCanceledError) {
|
||||
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 })
|
||||
}}
|
||||
schema={schema}
|
||||
invoiceable
|
||||
prepaid
|
||||
onSubmit={onSubmit}
|
||||
storageKeyPrefix={storageKeyPrefix}
|
||||
>
|
||||
|
@ -8,68 +8,85 @@ import Check from '@/svgs/checkbox-circle-fill.svg'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
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 }) {
|
||||
const me = useMe()
|
||||
const [pollVote] = useMutation(
|
||||
gql`
|
||||
mutation pollVote($id: ID!, $hash: String, $hmac: String) {
|
||||
pollVote(id: $id, hash: $hash, hmac: $hmac)
|
||||
}`, {
|
||||
update (cache, { data: { pollVote } }) {
|
||||
cache.modify({
|
||||
id: `Item:${item.id}`,
|
||||
fields: {
|
||||
poll (existingPoll) {
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
const POLL_VOTE_MUTATION = gql`
|
||||
mutation pollVote($id: ID!, $hash: String, $hmac: String) {
|
||||
pollVote(id: $id, hash: $hash, hmac: $hmac)
|
||||
}`
|
||||
const [pollVote] = useMutation(POLL_VOTE_MUTATION)
|
||||
const toaster = useToast()
|
||||
const { notify, unnotify } = useClientNotifications()
|
||||
|
||||
const update = (cache, { data: { pollVote } }) => {
|
||||
cache.modify({
|
||||
id: `Item:${item.id}`,
|
||||
fields: {
|
||||
poll (existingPoll) {
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const PollButton = ({ v }) => {
|
||||
const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => {
|
||||
await pollVote({ variables: { ...variables, hash, hmac } })
|
||||
}, [pollVote])
|
||||
|
||||
const variables = { id: v.id }
|
||||
|
||||
const payment = usePayment()
|
||||
return (
|
||||
<ActionTooltip placement='left' notForm overlayText='1 sat'>
|
||||
<Button
|
||||
variant='outline-info' className={styles.pollButton}
|
||||
onClick={me
|
||||
? async () => {
|
||||
const variables = { id: v.id }
|
||||
const notifyProps = { itemId: item.id }
|
||||
const optimisticResponse = { pollVote: v.id }
|
||||
let revert, cancel, nid
|
||||
try {
|
||||
await pollVote({
|
||||
variables,
|
||||
optimisticResponse: {
|
||||
pollVote: v.id
|
||||
}
|
||||
})
|
||||
revert = optimisticUpdate({ mutation: POLL_VOTE_MUTATION, variables, optimisticResponse, update })
|
||||
|
||||
if (me) {
|
||||
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) {
|
||||
if (payOrLoginError(error)) {
|
||||
showInvoiceModal({ amount: item.pollCost || POLL_COST }, { variables })
|
||||
revert?.()
|
||||
|
||||
if (error instanceof InvoiceCanceledError) {
|
||||
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}
|
||||
|
@ -14,7 +14,7 @@ export default function Qr ({ asIs, value, webLn, statusVariant, description, st
|
||||
async function effect () {
|
||||
if (webLn && provider) {
|
||||
try {
|
||||
await provider.sendPayment({ bolt11: value })
|
||||
await provider.sendPayment(value)
|
||||
} catch (e) {
|
||||
console.log(e?.message)
|
||||
}
|
||||
|
@ -166,7 +166,7 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
|
||||
text: ''
|
||||
}}
|
||||
schema={commentSchema}
|
||||
invoiceable
|
||||
prepaid
|
||||
onSubmit={onSubmit}
|
||||
storageKeyPrefix={`reply-${parentId}`}
|
||||
>
|
||||
|
@ -112,7 +112,7 @@ export default function TerritoryForm ({ sub }) {
|
||||
nsfw: sub?.nsfw || false
|
||||
}}
|
||||
schema={schema}
|
||||
invoiceable
|
||||
prepaid
|
||||
onSubmit={onSubmit}
|
||||
className='mb-5'
|
||||
storageKeyPrefix={sub ? undefined : 'territory'}
|
||||
|
@ -56,7 +56,7 @@ export default function TerritoryPaymentDue ({ sub }) {
|
||||
|
||||
<FeeButtonProvider baseLineItems={{ territory: TERRITORY_BILLING_OPTIONS('one')[sub.billingType.toLowerCase()] }}>
|
||||
<Form
|
||||
invoiceable
|
||||
prepaid
|
||||
initial={{
|
||||
name: sub.name
|
||||
}}
|
||||
|
@ -13,7 +13,7 @@ import Thumb from '@/svgs/thumb-up-fill.svg'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
import copy from 'clipboard-copy'
|
||||
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 { rehypeInlineCodeProperty } from '@/lib/md'
|
||||
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
|
||||
}
|
||||
|
||||
// if the link is to a youtube video, render the video
|
||||
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)
|
||||
if (youtube?.groups?.id) {
|
||||
const videoWrapperStyles = {
|
||||
maxWidth: topLevel ? '640px' : '320px',
|
||||
margin: '0.5rem 0',
|
||||
paddingRight: '15px'
|
||||
}
|
||||
|
||||
const { provider, id, meta } = parseEmbedUrl(href)
|
||||
|
||||
// Youtube video embed
|
||||
if (provider === 'youtube') {
|
||||
return (
|
||||
<div style={{ maxWidth: topLevel ? '640px' : '320px', paddingRight: '15px', margin: '0.5rem 0' }}>
|
||||
<div style={videoWrapperStyles}>
|
||||
<YouTube
|
||||
videoId={youtube.groups.id} className={styles.youtubeContainer} opts={{
|
||||
videoId={id} className={styles.videoContainer} opts={{
|
||||
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
|
||||
return <Img src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</Img>
|
||||
},
|
||||
|
@ -237,7 +237,7 @@ img.fullScreen {
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.youtubeContainer {
|
||||
.videoContainer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
@ -245,7 +245,7 @@ img.fullScreen {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.youtubeContainer iframe {
|
||||
.videoContainer iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
|
@ -10,21 +10,6 @@ const ToastContext = createContext(() => {})
|
||||
|
||||
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 => {
|
||||
// mark every previous toast with same tag as hidden
|
||||
if (toast.tag === tag && toast.id !== id) return { ...toast, hidden: true }
|
||||
@ -36,24 +21,15 @@ export const ToastProvider = ({ children }) => {
|
||||
const [toasts, setToasts] = useState([])
|
||||
const toastId = useRef(0)
|
||||
|
||||
const removeToast = useCallback(({ id, onCancel, tag }) => {
|
||||
const removeToast = useCallback(({ id, tag }) => {
|
||||
setToasts(toasts => toasts.filter(toast => {
|
||||
if (toast.id === id) {
|
||||
// remove the toast with the passed id with no exceptions
|
||||
return false
|
||||
}
|
||||
const sameTag = tag && tag === toast.tag
|
||||
if (!sameTag) {
|
||||
// don't touch toasts with different tags
|
||||
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
|
||||
// remove toasts with same tag
|
||||
return !sameTag
|
||||
}))
|
||||
}, [setToasts])
|
||||
|
||||
@ -63,14 +39,10 @@ export const ToastProvider = ({ children }) => {
|
||||
createdAt: +new Date(),
|
||||
id: toastId.current++
|
||||
}
|
||||
setToasts(toasts => ensureFlow(toasts, toast).map(mapHidden(toast)))
|
||||
setToasts(toasts => [...toasts, toast].map(mapHidden(toast)))
|
||||
return () => removeToast(toast)
|
||||
}, [setToasts, removeToast])
|
||||
|
||||
const endFlow = useCallback((flowId) => {
|
||||
setToasts(toasts => toasts.filter(toast => toast.flowId !== flowId))
|
||||
}, [setToasts])
|
||||
|
||||
const toaster = useMemo(() => ({
|
||||
success: (body, options) => {
|
||||
const toast = {
|
||||
@ -103,14 +75,13 @@ export const ToastProvider = ({ children }) => {
|
||||
...options
|
||||
}
|
||||
return dispatchToast(toast)
|
||||
},
|
||||
endFlow
|
||||
}), [dispatchToast, removeToast, endFlow])
|
||||
}
|
||||
}), [dispatchToast, removeToast])
|
||||
|
||||
// Only clear toasts with no cancel function on page navigation
|
||||
// since navigation should not interfere with being able to cancel an action.
|
||||
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)
|
||||
|
||||
return () => {
|
||||
@ -151,16 +122,9 @@ export const ToastProvider = ({ children }) => {
|
||||
{visibleToasts.map(toast => {
|
||||
const textStyle = toast.variant === 'warning' ? 'text-dark' : ''
|
||||
const onClose = () => {
|
||||
toast.onUndo?.()
|
||||
toast.onCancel?.()
|
||||
toast.onClose?.()
|
||||
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
|
||||
const unhidden = toast.hidden
|
||||
// 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'
|
||||
aria-label='close'
|
||||
onClick={onClose}
|
||||
>{buttonElement}
|
||||
><div className={`${styles.toastClose} ${textStyle}`}>X</div>
|
||||
</Button>
|
||||
</div>
|
||||
</ToastBody>
|
||||
@ -196,78 +160,3 @@ export const ToastProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
.toastUndo {
|
||||
font-style: normal;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toastCancel {
|
||||
font-style: italic;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toastClose {
|
||||
color: #fff;
|
||||
font-family: "lightning";
|
||||
|
@ -2,7 +2,7 @@ import UpBolt from '@/svgs/bolt.svg'
|
||||
import styles from './upvote.module.css'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
import ItemAct, { useAct, useZap } from './item-act'
|
||||
import ItemAct, { ZapUndoController, useZap } from './item-act'
|
||||
import { useMe } from './me'
|
||||
import getColor from '@/lib/rainbow'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
@ -59,7 +59,7 @@ export function DropdownItemUpVote ({ item }) {
|
||||
<Dropdown.Item
|
||||
onClick={async () => {
|
||||
showModal(onClose =>
|
||||
<ItemAct onClose={onClose} itemId={item.id} />)
|
||||
<ItemAct onClose={onClose} item={item} />)
|
||||
}}
|
||||
>
|
||||
<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) => {
|
||||
if (!me) return
|
||||
|
||||
@ -125,7 +128,6 @@ export default function UpVote ({ item, className }) {
|
||||
}
|
||||
}, [me, tipShow, setWalkthrough])
|
||||
|
||||
const [act] = useAct()
|
||||
const zap = useZap()
|
||||
|
||||
const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt,
|
||||
@ -155,11 +157,19 @@ export default function UpVote ({ item, className }) {
|
||||
}
|
||||
|
||||
setTipShow(false)
|
||||
|
||||
if (pending) {
|
||||
controller.abort()
|
||||
return
|
||||
}
|
||||
const c = new ZapUndoController()
|
||||
setController(c)
|
||||
|
||||
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 (!item) return
|
||||
|
||||
@ -174,9 +184,16 @@ export default function UpVote ({ item, className }) {
|
||||
setTipShow(true)
|
||||
}
|
||||
|
||||
zap({ item, me })
|
||||
if (pending) {
|
||||
controller.abort()
|
||||
return
|
||||
}
|
||||
const c = new ZapUndoController()
|
||||
setController(c)
|
||||
|
||||
await zap({ item, me, abortSignal: c.signal })
|
||||
} 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}
|
||||
${className || ''}
|
||||
${disabled ? styles.noSelfTips : ''}
|
||||
${meSats ? styles.voted : ''}`
|
||||
${meSats ? styles.voted : ''}
|
||||
${pending ? styles.pending : ''}`
|
||||
}
|
||||
style={meSats || hover
|
||||
? {
|
||||
|
@ -34,4 +34,18 @@
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
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 TwitterIcon from '@/svgs/twitter-fill.svg'
|
||||
import { UNKNOWN_LINK_REL, MEDIA_URL } from '@/lib/constants'
|
||||
import ItemPopover from './item-popover'
|
||||
|
||||
export default function UserHeader ({ user }) {
|
||||
const router = useRouter()
|
||||
@ -284,7 +285,11 @@ function HeaderHeader ({ user }) {
|
||||
</Button>
|
||||
<div className='d-flex flex-column mt-1 ms-0'>
|
||||
<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>}
|
||||
</small>
|
||||
{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 { NWCProvider, useNWC } from './nwc'
|
||||
import { useToast, withToastFlow } from '@/components/toast'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import { LNCProvider, useLNC } from './lnc'
|
||||
|
||||
const WebLNContext = createContext({})
|
||||
@ -86,31 +84,6 @@ function RawWebLNProvider ({ children }) {
|
||||
// TODO: implement fallbacks via provider priority
|
||||
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) => {
|
||||
// move provider to the start to set it as default
|
||||
setEnabledProviders(providers => {
|
||||
@ -129,8 +102,17 @@ function RawWebLNProvider ({ children }) {
|
||||
await lnc.clearConfig()
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({
|
||||
provider: isEnabled(provider)
|
||||
? { name: provider.name, sendPayment: provider.sendPayment }
|
||||
: null,
|
||||
enabledProviders,
|
||||
setProvider,
|
||||
clearConfig
|
||||
}), [provider, enabledProviders, setProvider])
|
||||
|
||||
return (
|
||||
<WebLNContext.Provider value={{ provider: isEnabled(provider) ? { name: provider.name, sendPayment: sendPaymentWithToast } : null, enabledProviders, setProvider, clearConfig }}>
|
||||
<WebLNContext.Provider value={value}>
|
||||
{children}
|
||||
</WebLNContext.Provider>
|
||||
)
|
||||
|
@ -9,6 +9,7 @@ import CancelButton from '../cancel-button'
|
||||
import { Mutex } from 'async-mutex'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
import { useMe } from '../me'
|
||||
import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment'
|
||||
|
||||
const LNCContext = createContext()
|
||||
const mutex = new Mutex()
|
||||
@ -109,7 +110,14 @@ export function LNCProvider ({ children }) {
|
||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
||||
return { preimage }
|
||||
} 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
|
||||
} finally {
|
||||
try {
|
||||
|
@ -6,8 +6,9 @@ import { parseNwcUrl } from '@/lib/url'
|
||||
import { useWalletLogger } from '../logger'
|
||||
import { Status, migrateLocalStorage } from '.'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
import { JIT_INVOICE_TIMEOUT_MS, Wallet } from '@/lib/constants'
|
||||
import { useMe } from '../me'
|
||||
import { InvoiceExpiredError } from '../payment'
|
||||
|
||||
const NWCContext = createContext()
|
||||
|
||||
@ -205,11 +206,11 @@ export function NWCProvider ({ children }) {
|
||||
(async function () {
|
||||
// timeout since NWC is async (user needs to confirm payment in wallet)
|
||||
// timeout is same as invoice expiry
|
||||
const timeout = 180_000
|
||||
const timeout = JIT_INVOICE_TIMEOUT_MS
|
||||
const timer = setTimeout(() => {
|
||||
const msg = 'timeout waiting for info event'
|
||||
const msg = 'timeout waiting for payment'
|
||||
logger.error(msg)
|
||||
reject(new Error(msg))
|
||||
reject(new InvoiceExpiredError(hash))
|
||||
sub?.close()
|
||||
}, 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 JIT_INVOICE_TIMEOUT_MS = 180_000
|
||||
|
||||
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)
|
||||
@ -148,3 +149,5 @@ export const getWalletBy = (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) {
|
||||
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
|
||||
"style-src 'self' a.stacker.news 'unsafe-inline'",
|
||||
"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,
|
||||
// disable dangerous plugins like Flash
|
||||
"object-src 'none'",
|
||||
|
@ -21,6 +21,7 @@ import { ChainFeeProvider } from '@/components/chain-fee.js'
|
||||
import { WebLNProvider } from '@/components/webln'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
|
||||
import { ClientNotificationProvider } from '@/components/client-notifications'
|
||||
|
||||
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
|
||||
|
||||
@ -104,28 +105,30 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
||||
<ApolloProvider client={client}>
|
||||
<MeProvider me={me}>
|
||||
<HasNewNotesProvider>
|
||||
<LoggerProvider>
|
||||
<ServiceWorkerProvider>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ToastProvider>
|
||||
<WebLNProvider>
|
||||
<ShowModalProvider>
|
||||
<BlockHeightProvider blockHeight={blockHeight}>
|
||||
<ChainFeeProvider chainFee={chainFee}>
|
||||
<ErrorBoundary>
|
||||
<Component ssrData={ssrData} {...otherProps} />
|
||||
{!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} />}
|
||||
</ErrorBoundary>
|
||||
</ChainFeeProvider>
|
||||
</BlockHeightProvider>
|
||||
</ShowModalProvider>
|
||||
</WebLNProvider>
|
||||
</ToastProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</LoggerProvider>
|
||||
<ClientNotificationProvider>
|
||||
<LoggerProvider>
|
||||
<ServiceWorkerProvider>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ToastProvider>
|
||||
<WebLNProvider>
|
||||
<ShowModalProvider>
|
||||
<BlockHeightProvider blockHeight={blockHeight}>
|
||||
<ChainFeeProvider chainFee={chainFee}>
|
||||
<ErrorBoundary>
|
||||
<Component ssrData={ssrData} {...otherProps} />
|
||||
{!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} />}
|
||||
</ErrorBoundary>
|
||||
</ChainFeeProvider>
|
||||
</BlockHeightProvider>
|
||||
</ShowModalProvider>
|
||||
</WebLNProvider>
|
||||
</ToastProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</LoggerProvider>
|
||||
</ClientNotificationProvider>
|
||||
</HasNewNotesProvider>
|
||||
</MeProvider>
|
||||
</ApolloProvider>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { Invoice } from '@/components/invoice'
|
||||
import Invoice from '@/components/invoice'
|
||||
import { QrSkeleton } from '@/components/qr'
|
||||
import { CenterLayout } from '@/components/layout'
|
||||
import { useRouter } from 'next/router'
|
||||
@ -10,6 +10,7 @@ import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
// force SSR to include CSP nonces
|
||||
export const getServerSideProps = getGetServerSideProps({ query: null })
|
||||
|
||||
// TODO: we can probably replace this component with <Invoice poll>
|
||||
export default function FullInvoice () {
|
||||
const router = useRouter()
|
||||
const { data, error } = useQuery(INVOICE, SSR
|
||||
|
@ -174,7 +174,7 @@ export function DonateButton () {
|
||||
amount: 10000
|
||||
}}
|
||||
schema={amountSchema}
|
||||
invoiceable
|
||||
prepaid
|
||||
onSubmit={async ({ amount, hash, hmac }) => {
|
||||
const { error } = await donateToRewards({
|
||||
variables: {
|
||||
|
@ -26,7 +26,7 @@ import { NostrAuth } from '@/components/nostr-auth'
|
||||
import { useToast } from '@/components/toast'
|
||||
import { useServiceWorkerLogger } from '@/components/logger'
|
||||
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 DeleteIcon from '@/svgs/delete-bin-line.svg'
|
||||
import { useField } from 'formik'
|
||||
@ -1007,7 +1007,7 @@ const ZapUndosField = () => {
|
||||
<Info>
|
||||
<ul className='fw-bold'>
|
||||
<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>Use a budget or manual approval with attached wallets</li>
|
||||
</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:]]*$//')
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -15,7 +15,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.youtubeContainer {
|
||||
.videoWrapper {
|
||||
max-width: 640px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.videoContainer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
@ -23,7 +28,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.youtubeContainer iframe {
|
||||
.videoContainer iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
@ -36,16 +41,11 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.youtubeContainerContainer {
|
||||
max-width: 640px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.twitterContainer:not(:first-child) {
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.youtubeContainerContainer:not(:first-child) {
|
||||
.videoWrapper:not(:first-child) {
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user