Compare commits

..

No commits in common. "033270b6aef6ea4bc730f8d329e9a833ffb3984c" and "9c5bec06fbebddf51f71ccc220b34a581c3fabac" have entirely different histories.

45 changed files with 937 additions and 1095 deletions

View File

@ -899,7 +899,7 @@ export default {
WHERE act IN ('TIP', 'FEE')
AND "itemId" = ${Number(id)}::INTEGER
AND "userId" = ${me.id}::INTEGER)::INTEGER)`,
{ models, lnd, hash, hmac }
{ models }
)
} else {
await serialize(

View File

@ -99,8 +99,3 @@ 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

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
99 SatsAllDay pr #1159 #510 medium-hard 1 450k weareallsatoshi@getalby.com 2024-05-22
100 Darth-Coin issue #1159 #510 medium-hard 1 45k darthcoin@stacker.news 2024-05-22
101 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

View File

@ -107,8 +107,7 @@ export function BountyForm ({
...SubSelectInitial({ sub: item?.subName || sub?.name })
}}
schema={schema}
requireSession
prepaid
invoiceable={{ requireSession: true }}
onSubmit={
handleSubmit ||
onSubmit

View File

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

View File

@ -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} item={item} />
? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} />
: 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'>

View File

@ -96,7 +96,7 @@ export function DiscussionForm ({
...SubSelectInitial({ sub: item?.subName || sub?.name })
}}
schema={schema}
prepaid
invoiceable
onSubmit={handleSubmit || onSubmit}
storageKeyPrefix={storageKeyPrefix}
>

View File

@ -1,15 +1,15 @@
import Dropdown from 'react-bootstrap/Dropdown'
import { useShowModal } from './modal'
import { useToast } from './toast'
import ItemAct from './item-act'
import ItemAct, { zapUndosThresholdReached } 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 ({ item, ...props }) {
const { meDontLikeSats } = item
export function DownZap ({ id, meDontLikeSats, ...props }) {
const style = useMemo(() => (meDontLikeSats
? {
fill: getColor(meDontLikeSats),
@ -17,13 +17,14 @@ export function DownZap ({ item, ...props }) {
}
: undefined), [meDontLikeSats])
return (
<DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
<DownZapper id={id} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
)
}
function DownZapper ({ item, As, children }) {
function DownZapper ({ id, As, children }) {
const toaster = useToast()
const showModal = useShowModal()
const me = useMe()
return (
<As
@ -31,7 +32,12 @@ function DownZapper ({ item, As, children }) {
try {
showModal(onClose =>
<ItemAct
onClose={onClose} item={item} down
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
>
<AccordianItem
header='what is a downzap?' body={
@ -53,11 +59,11 @@ function DownZapper ({ item, As, children }) {
)
}
export default function DontLikeThisDropdownItem ({ item }) {
export default function DontLikeThisDropdownItem ({ id }) {
return (
<DownZapper
As={Dropdown.Item}
item={item}
id={id}
>
<span className='text-danger'>downzap</span>
</DownZapper>

View File

@ -64,7 +64,6 @@ 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()
@ -77,18 +76,14 @@ 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,
total: Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0),
disabled,
setDisabled,
free
setDisabled
}
}, [me?.privates?.sats, baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled])
}, [baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled])
return (
<FeeButtonContext.Provider value={value}>
@ -116,7 +111,9 @@ function FreebieDialog () {
export default function FeeButton ({ ChildButton = SubmitButton, variant, text, disabled }) {
const me = useMe()
const { lines, total, disabled: ctxDisabled, free } = useFeeButton()
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 feeText = free
? 'free'
: total > 1

View File

@ -18,6 +18,7 @@ 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'
@ -31,18 +32,6 @@ 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
@ -804,16 +793,11 @@ const StorageKeyPrefixContext = createContext()
export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately,
storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef,
optimisticUpdate: optimisticUpdateArgs, clientNotification, signal, ...props
storageKeyPrefix, validateOnChange = true, invoiceable, innerRef, ...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?.())
@ -836,57 +820,37 @@ export function Form ({
})
}, [storageKeyPrefix])
const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => {
const variables = { amount, ...values }
let revert, cancel, nid
// 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) => {
try {
if (onSubmit) {
if (requireSession && !me) {
throw new SessionRequiredError()
// 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 (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)
await onSubmit(values, ...args)
if (!storageKeyPrefix) return
clearLocalStorage(values)
}
} catch (err) {
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)
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)
}
}, [me, onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix, payment, signal])
}, [onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix])
return (
<Formik

View File

@ -1,35 +1,23 @@
import { useState, useEffect } from 'react'
import { useState, useCallback, useEffect } from 'react'
import { useApolloClient, useMutation, useQuery } from '@apollo/client'
import { Button } from 'react-bootstrap'
import { gql } from 'graphql-tag'
import { numWithUnits } from '@/lib/format'
import AccordianItem from './accordian-item'
import Qr from './qr'
import Qr, { QrSkeleton } from './qr'
import { INVOICE } from '@/fragments/wallet'
import InvoiceStatus from './invoice-status'
import { useMe } from './me'
import { useShowModal } from './modal'
import Countdown from './countdown'
import PayerData from './payer-data'
import Bolt11Info from './bolt11-info'
import { useQuery } from '@apollo/client'
import { INVOICE } from '@/fragments/wallet'
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { WebLnNotEnabledError } from './payment'
import { useWebLN } from './webln'
import { FAST_POLL_INTERVAL } from '@/lib/constants'
export default function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn, webLnError, poll }) {
export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }) {
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
@ -60,11 +48,6 @@ export default function Invoice ({ invoice, modal, onPayment, info, successVerb,
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 })}
@ -122,3 +105,289 @@ export default function Invoice ({ invoice, modal, onPayment, info, successVerb,
</>
)
}
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))
}

View File

@ -5,16 +5,13 @@ import { Form, Input, SubmitButton } from './form'
import { useMe } from './me'
import UpBolt from '@/svgs/bolt.svg'
import { amountSchema } from '@/lib/validate'
import { gql, useMutation } from '@apollo/client'
import { useToast } from './toast'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import { payOrLoginError, useInvoiceModal } from './invoice'
import { TOAST_DEFAULT_DELAY_MS, useToast, withToastFlow } 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, 10_000, 100_000]
const defaultTips = [100, 1000, 10000, 100000]
const Tips = ({ setOValue }) => {
const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b)
@ -44,149 +41,222 @@ const addCustomTip = (amount) => {
window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
}
const setItemMeAnonSats = ({ id, amount }) => {
const storageKey = `TIP-item:${id}`
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
window.localStorage.setItem(storageKey, existingAmount + amount)
export const zapUndosThresholdReached = (me, amount) => {
if (!me) return false
const enabled = me.privates.zapUndos !== null
return enabled ? amount >= me.privates.zapUndos : false
}
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 }) {
export default function ItemAct ({ onClose, itemId, down, children }) {
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, item.id])
}, [onClose, itemId])
const act = useAct()
const [act, actUpdate] = useAct()
const onSubmit = useCallback(async ({ amount, hash, hmac }) => {
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)
}
await act({
variables: {
id: item.id,
id: itemId,
sats: Number(amount),
act: down ? 'DONT_LIKE_THIS' : 'TIP',
hash,
hmac
}
},
update
})
if (!me) setItemMeAnonSats({ id: item.id, amount })
// only strike when zap undos not enabled
// due to optimistic UX on zap undos
if (!zapUndosThresholdReached(me, Number(amount))) await strike()
addCustomTip(Number(amount))
}, [me, act, down, item.id, strike])
if (!keepOpen) onClose(Number(amount))
}, [me, act, down, itemId, strike])
const optimisticUpdate = useCallback(({ amount }) => {
const variables = {
id: item.id,
sats: Number(amount),
act: down ? 'DONT_LIKE_THIS' : 'TIP'
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 optimisticResponse = { act: { ...variables, path: item.path } }
strike()
onClose()
return { mutation: ACT_MUTATION, variables, optimisticResponse, update: actUpdate({ me }) }
}, [item.id, down, !!me, strike])
)
return (
<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>
<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>
)
}
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
}
}`
export function useAct ({ onUpdate } = {}) {
const [act] = useMutation(ACT_MUTATION)
return act
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)
}
}, [!!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 useZap () {
@ -237,107 +307,118 @@ export function useZap () {
}
}, [])
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 [zap] = useMutation(
gql`
mutation idempotentAct($id: ID!, $sats: Int!) {
act(id: $id, sats: $sats, idempotent: true) {
id
sats
path
}
}`
)
const toaster = useToast()
const strike = useLightning()
const payment = usePayment()
const [act] = useAct()
const client = useApolloClient()
return useCallback(async ({ item, mem, abortSignal }) => {
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 }) => {
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 satsDelta = sats - meSats
const amount = sats - meSats
const variables = { id: item.id, sats, act: 'TIP' }
const notifyProps = { itemId: item.id, sats: satsDelta }
const variables = { id: item.id, sats, act: 'TIP', amount }
const insufficientFunds = me?.privates.sats < amount
const optimisticResponse = { act: { path: item.path, ...variables } }
let revert, cancel, nid
const flowId = (+new Date()).toString(16)
const zapArgs = { variables, optimisticResponse: insufficientFunds ? null : optimisticResponse, update, flowId }
try {
revert = optimisticUpdate({ mutation: ZAP_MUTATION, variables, optimisticResponse, update })
if (insufficientFunds) throw new Error('insufficient funds')
strike()
await abortSignal.pause({ me, amount: satsDelta })
if (me) {
nid = notify(ClientNotification.Zap.PENDING, notifyProps)
if (zapUndosThresholdReached(me, amount)) {
await zapWithUndos(zapArgs)
} else {
await zap(zapArgs)
}
let hash, hmac;
[{ hash, hmac }, cancel] = await payment.request(satsDelta)
await zap({ variables: { ...variables, hash, hmac } })
} catch (error) {
revert?.()
if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) {
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) {}
return
}
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)
console.error(error)
toaster.danger('zap: ' + error?.message || error?.toString?.())
}
}, [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)
})
}

View File

@ -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, parseEmbedUrl } from '@/lib/url'
import { IMGPROXY_URL_REGEXP } from '@/lib/url'
import { numWithUnits } from '@/lib/format'
import { useQuoteReply } from './use-quote-reply'
import { UNKNOWN_LINK_REL } from '@/lib/constants'
@ -70,7 +70,6 @@ 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 (
@ -84,15 +83,14 @@ function ItemEmbed ({ item }) {
)
}
const { provider, id, meta } = parseEmbedUrl(item.url)
if (provider === 'youtube') {
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) {
return (
<div className={styles.videoWrapper}>
<div className={styles.youtubeContainerContainer}>
<YouTube
videoId={id} className={styles.videoContainer} opts={{
videoId={youtube.groups.id} className={styles.youtubeContainer} opts={{
playerVars: {
start: meta?.start || 0
start: youtube?.groups?.start
}
}}
/>
@ -100,20 +98,6 @@ 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} />
}

View File

@ -176,7 +176,7 @@ export default function ItemInfo ({
!item.mine && !item.deletedAt &&
(item.meDontLikeSats > meTotalSats
? <DropdownItemUpVote item={item} />
: <DontLikeThisDropdownItem item={item} />)}
: <DontLikeThisDropdownItem id={item.id} />)}
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
<>
<hr className='dropdown-divider' />

View File

@ -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} item={item} />
? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} />
: Number(item.user?.id) === AD_USER_ID
? <AdIcon width={24} height={24} className={styles.ad} />
: <UpVote item={item} className={styles.upvote} />}

View File

@ -105,8 +105,7 @@ export default function JobForm ({ item, sub }) {
}}
schema={jobSchema}
storageKeyPrefix={storageKeyPrefix}
requireSession
prepaid
invoiceable={{ requireSession: true }}
onSubmit={onSubmit}
>
<div className='form-group'>

View File

@ -143,7 +143,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
...SubSelectInitial({ sub: item?.subName || sub?.name })
}}
schema={schema}
prepaid
invoiceable
onSubmit={onSubmit}
storageKeyPrefix={storageKeyPrefix}
>

View File

@ -30,19 +30,12 @@ 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(itemN)} fresh={fresh}>
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
{
(type === 'Earn' && <EarnNotification n={n} />) ||
(type === 'Revenue' && <RevenueNotification n={n} />) ||
@ -60,11 +53,7 @@ function Notification ({ n, fresh }) {
(type === 'FollowActivity' && <FollowActivity n={n} />) ||
(type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
(type === 'TerritoryTransfer' && <TerritoryTransfer 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} />)
(type === 'Reminder' && <Reminder n={n} />)
}
</NotificationLayout>
)
@ -113,8 +102,6 @@ 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)
@ -547,7 +534,6 @@ 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 {}
@ -575,12 +561,9 @@ 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 (
<>
{sorted.map(n =>
{notifications.map(n =>
<Notification
n={n} key={nid(n)}
fresh={new Date(n.sortTime) > new Date(router?.query?.checkedAt ?? lastChecked)}

View File

@ -6,23 +6,15 @@ import { useMe } from './me'
import { numWithUnits } from '@/lib/format'
import { useShowModal } from './modal'
import { useRoot } from './root'
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'
import { payOrLoginError, useInvoiceModal } from './invoice'
import { useAct } from './item-act'
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(onComplete => (cache, { data: { act: { id, path } } }) => {
const onUpdate = useCallback((cache, { data: { act: { id, path } } }) => {
// update root bounty status
const root = path.split('.')[0]
cache.modify({
@ -33,55 +25,30 @@ export default function PayBounty ({ children, item }) {
}
}
})
strike()
onComplete()
}, [strike])
}, [])
const act = useAct()
const [act] = useAct({ onUpdate })
const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => {
await act({ variables: { ...variables, hash, hmac } })
}, [act])
const handlePayBounty = async onComplete => {
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
const variables = { id: item.id, sats: root.bounty, act: 'TIP', path: item.path }
try {
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 },
variables,
optimisticResponse: {
act: variables
}
})
onComplete()
} catch (error) {
revert?.()
if (error instanceof InvoiceCanceledError) {
if (payOrLoginError(error)) {
showInvoiceModal({ amount: root.bounty }, { variables })
return
}
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)
throw new Error({ message: error.toString() })
}
}

View File

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

View File

@ -86,7 +86,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
...SubSelectInitial({ sub: item?.subName || sub?.name })
}}
schema={schema}
prepaid
invoiceable
onSubmit={onSubmit}
storageKeyPrefix={storageKeyPrefix}
>

View File

@ -8,85 +8,68 @@ 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 { InvoiceCanceledError, usePayment } from './payment'
import { optimisticUpdate } from '@/lib/apollo'
import { useToast } from './toast'
import { Types as ClientNotification, useClientNotifications } from './client-notifications'
import { payOrLoginError, useInvoiceModal } from './invoice'
export default function Poll ({ item }) {
const me = useMe()
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
}
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
}
}
})
}
})
cache.modify({
id: `PollOption:${pollVote}`,
fields: {
count (existingCount) {
return existingCount + 1
},
meVoted () {
return true
}
}
})
}
}
)
const PollButton = ({ v }) => {
const payment = usePayment()
const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => {
await pollVote({ variables: { ...variables, hash, hmac } })
}, [pollVote])
const variables = { id: v.id }
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 {
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 } })
await pollVote({
variables,
optimisticResponse: {
pollVote: v.id
}
})
} catch (error) {
revert?.()
if (error instanceof InvoiceCanceledError) {
if (payOrLoginError(error)) {
showInvoiceModal({ amount: item.pollCost || POLL_COST }, { variables })
return
}
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)
throw new Error({ message: error.toString() })
}
}
: signIn}

View File

@ -14,7 +14,7 @@ export default function Qr ({ asIs, value, webLn, statusVariant, description, st
async function effect () {
if (webLn && provider) {
try {
await provider.sendPayment(value)
await provider.sendPayment({ bolt11: value })
} catch (e) {
console.log(e?.message)
}

View File

@ -166,7 +166,7 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
text: ''
}}
schema={commentSchema}
prepaid
invoiceable
onSubmit={onSubmit}
storageKeyPrefix={`reply-${parentId}`}
>

View File

@ -112,7 +112,7 @@ export default function TerritoryForm ({ sub }) {
nsfw: sub?.nsfw || false
}}
schema={schema}
prepaid
invoiceable
onSubmit={onSubmit}
className='mb-5'
storageKeyPrefix={sub ? undefined : 'territory'}

View File

@ -56,7 +56,7 @@ export default function TerritoryPaymentDue ({ sub }) {
<FeeButtonProvider baseLineItems={{ territory: TERRITORY_BILLING_OPTIONS('one')[sub.billingType.toLowerCase()] }}>
<Form
prepaid
invoiceable
initial={{
name: sub.name
}}

View File

@ -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, parseEmbedUrl } from '@/lib/url'
import { IMGPROXY_URL_REGEXP, parseInternalLinks } from '@/lib/url'
import reactStringReplace from 'react-string-replace'
import { rehypeInlineCodeProperty } from '@/lib/md'
import { Button } from 'react-bootstrap'
@ -238,22 +238,15 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
// ignore errors like invalid URLs
}
const videoWrapperStyles = {
maxWidth: topLevel ? '640px' : '320px',
margin: '0.5rem 0',
paddingRight: '15px'
}
const { provider, id, meta } = parseEmbedUrl(href)
// Youtube video embed
if (provider === 'youtube') {
// 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) {
return (
<div style={videoWrapperStyles}>
<div style={{ maxWidth: topLevel ? '640px' : '320px', paddingRight: '15px', margin: '0.5rem 0' }}>
<YouTube
videoId={id} className={styles.videoContainer} opts={{
videoId={youtube.groups.id} className={styles.youtubeContainer} opts={{
playerVars: {
start: meta?.start || 0
start: youtube?.groups?.start
}
}}
/>
@ -261,21 +254,6 @@ 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>
},

View File

@ -237,7 +237,7 @@ img.fullScreen {
font-size: .85rem;
}
.videoContainer {
.youtubeContainer {
position: relative;
width: 100%;
height: 0;
@ -245,7 +245,7 @@ img.fullScreen {
overflow: hidden;
}
.videoContainer iframe {
.youtubeContainer iframe {
width: 100%;
height: 100%;
position: absolute;

View File

@ -10,6 +10,21 @@ 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 }
@ -21,15 +36,24 @@ export const ToastProvider = ({ children }) => {
const [toasts, setToasts] = useState([])
const toastId = useRef(0)
const removeToast = useCallback(({ id, tag }) => {
const removeToast = useCallback(({ id, onCancel, 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
// remove toasts with same tag
return !sameTag
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
}))
}, [setToasts])
@ -39,10 +63,14 @@ export const ToastProvider = ({ children }) => {
createdAt: +new Date(),
id: toastId.current++
}
setToasts(toasts => [...toasts, toast].map(mapHidden(toast)))
setToasts(toasts => ensureFlow(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 = {
@ -75,13 +103,14 @@ export const ToastProvider = ({ children }) => {
...options
}
return dispatchToast(toast)
}
}), [dispatchToast, removeToast])
},
endFlow
}), [dispatchToast, removeToast, endFlow])
// 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(({ persistOnNavigate }) => persistOnNavigate) : toasts)
const handleRouteChangeStart = () => setToasts(toasts => toasts.length > 0 ? toasts.filter(({ onCancel, onUndo, persistOnNavigate }) => onCancel || onUndo || persistOnNavigate) : toasts)
router.events.on('routeChangeStart', handleRouteChangeStart)
return () => {
@ -122,9 +151,16 @@ 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.
@ -145,7 +181,7 @@ export const ToastProvider = ({ children }) => {
className='p-0 ps-2'
aria-label='close'
onClick={onClose}
><div className={`${styles.toastClose} ${textStyle}`}>X</div>
>{buttonElement}
</Button>
</div>
</ToastBody>
@ -160,3 +196,78 @@ 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
}

View File

@ -21,6 +21,20 @@
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";

View File

@ -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, { ZapUndoController, useZap } from './item-act'
import ItemAct, { useAct, 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} item={item} />)
<ItemAct onClose={onClose} itemId={item.id} />)
}}
>
<span className='text-success'>zap</span>
@ -97,9 +97,6 @@ export default function UpVote ({ item, className }) {
}`
)
const [controller, setController] = useState(null)
const pending = controller?.started && !controller.done
const setVoteShow = useCallback((yes) => {
if (!me) return
@ -128,6 +125,7 @@ export default function UpVote ({ item, className }) {
}
}, [me, tipShow, setWalkthrough])
const [act] = useAct()
const zap = useZap()
const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt,
@ -157,19 +155,11 @@ export default function UpVote ({ item, className }) {
}
setTipShow(false)
if (pending) {
controller.abort()
return
}
const c = new ZapUndoController()
setController(c)
showModal(onClose =>
<ItemAct onClose={onClose} item={item} abortSignal={c.signal} />, { onClose: handleModalClosed })
<ItemAct onClose={onClose} itemId={item.id} />, { onClose: handleModalClosed })
}
const handleShortPress = async () => {
const handleShortPress = () => {
if (me) {
if (!item) return
@ -184,16 +174,9 @@ export default function UpVote ({ item, className }) {
setTipShow(true)
}
if (pending) {
controller.abort()
return
}
const c = new ZapUndoController()
setController(c)
await zap({ item, me, abortSignal: c.signal })
zap({ item, me })
} else {
showModal(onClose => <ItemAct onClose={onClose} item={item} />, { onClose: handleModalClosed })
showModal(onClose => <ItemAct onClose={onClose} itemId={item.id} act={act} />, { onClose: handleModalClosed })
}
}
@ -219,8 +202,7 @@ export default function UpVote ({ item, className }) {
`${styles.upvote}
${className || ''}
${disabled ? styles.noSelfTips : ''}
${meSats ? styles.voted : ''}
${pending ? styles.pending : ''}`
${meSats ? styles.voted : ''}`
}
style={meSats || hover
? {

View File

@ -34,18 +34,4 @@
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;
}
}
}

View File

@ -29,7 +29,6 @@ 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()
@ -285,11 +284,7 @@ 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
? (
<ItemPopover id={user.since}>
<Link href={`/items/${user.since}`} className='ms-1'>#{user.since}</Link>
</ItemPopover>
)
? <Link href={`/items/${user.since}`} className='ms-1'>#{user.since}</Link>
: <span>never</span>}
</small>
{user.optional.maxStreak !== null &&

View File

@ -1,6 +1,8 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { createContext, useCallback, useContext, useEffect, 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({})
@ -84,6 +86,31 @@ 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 => {
@ -102,17 +129,8 @@ 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={value}>
<WebLNContext.Provider value={{ provider: isEnabled(provider) ? { name: provider.name, sendPayment: sendPaymentWithToast } : null, enabledProviders, setProvider, clearConfig }}>
{children}
</WebLNContext.Provider>
)

View File

@ -9,7 +9,6 @@ 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()
@ -110,14 +109,7 @@ export function LNCProvider ({ children }) {
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
return { preimage }
} catch (err) {
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)
}
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
throw err
} finally {
try {

View File

@ -6,9 +6,8 @@ import { parseNwcUrl } from '@/lib/url'
import { useWalletLogger } from '../logger'
import { Status, migrateLocalStorage } from '.'
import { bolt11Tags } from '@/lib/bolt11'
import { JIT_INVOICE_TIMEOUT_MS, Wallet } from '@/lib/constants'
import { Wallet } from '@/lib/constants'
import { useMe } from '../me'
import { InvoiceExpiredError } from '../payment'
const NWCContext = createContext()
@ -206,11 +205,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 = JIT_INVOICE_TIMEOUT_MS
const timeout = 180_000
const timer = setTimeout(() => {
const msg = 'timeout waiting for payment'
const msg = 'timeout waiting for info event'
logger.error(msg)
reject(new InvoiceExpiredError(hash))
reject(new Error(msg))
sub?.close()
}, timeout)

View File

@ -255,17 +255,3 @@ 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)
}

View File

@ -126,7 +126,6 @@ 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)
@ -149,5 +148,3 @@ export const getWalletBy = (key, value) => {
}
throw new Error(`wallet not found: ${key}=${value}`)
}
export const ZAP_UNDO_DELAY_MS = 5_000

View File

@ -52,45 +52,6 @@ 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
}

View File

@ -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 rumble.com',
'frame-src www.youtube.com platform.twitter.com',
"connect-src 'self' https: wss:" + devSrc,
// disable dangerous plugins like Flash
"object-src 'none'",

View File

@ -21,7 +21,6 @@ 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 })
@ -105,30 +104,28 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
<ApolloProvider client={client}>
<MeProvider me={me}>
<HasNewNotesProvider>
<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>
<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>
</HasNewNotesProvider>
</MeProvider>
</ApolloProvider>

View File

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

View File

@ -174,7 +174,7 @@ export function DonateButton () {
amount: 10000
}}
schema={amountSchema}
prepaid
invoiceable
onSubmit={async ({ amount, hash, hmac }) => {
const { error } = await donateToRewards({
variables: {

View File

@ -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, ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { INVOICE_RETENTION_DAYS } 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 {ZAP_UNDO_DELAY_MS / 1000} seconds</li>
<li>The button is shown for 5 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
View File

@ -400,8 +400,7 @@ __sndev__pr_track() {
ref=$(echo "$json" | grep -e '"ref"' | head -n1 | sed -e 's/^.*"ref":[[:space:]]*"//; s/",[[:space:]]*$//')
git fetch "$remote" "$ref"
git checkout -t -b "pr/$1" "$remote/$ref"
git config --local "remote.$remote.push" pr/$1:$ref
git checkout -b "pr/$1" "$remote/$ref"
exit 0
}

View File

@ -15,12 +15,7 @@
}
}
.videoWrapper {
max-width: 640px;
padding-right: 15px;
}
.videoContainer {
.youtubeContainer {
position: relative;
width: 100%;
height: 0;
@ -28,7 +23,7 @@
overflow: hidden;
}
.videoContainer iframe {
.youtubeContainer iframe {
width: 100%;
height: 100%;
position: absolute;
@ -41,11 +36,16 @@
position: relative;
}
.youtubeContainerContainer {
max-width: 640px;
padding-right: 15px;
}
.twitterContainer:not(:first-child) {
margin-top: .75rem;
}
.videoWrapper:not(:first-child) {
.youtubeContainerContainer:not(:first-child) {
margin-top: .75rem;
}