stacker.news/components/notifications.js
ekzyis 93713b33df
Optimistic updates via pending sats in item context (#1229)
* Use context for pending sats

* Fix sats going negative on zap undo

We already handle undoing pending sats by wrapping the payment+mutation with try/finally.

* Remove unnecessary ItemContextProvider

* Rename to parentCtx

* Fix hierarchy of ItemContextProvider

If a comment was root and it was zapped, the pending sats contributed to the sats shown in <CommentsHeader>.

This was caused by <CommentsHeader> accessing the root item context for all comments, even for the root comment.

So even if the root comment was zapped, the pending sats contributed to the sats for the comment section.

This wasn't the case for posts since their item context was above the context used by <CommentsHeader>.

This was fixed by moving <ItemProviderContext> down into <Comments> and <Item> instead of declaring it at <ItemFull> which wraps the root item and all comments.

* Optimistic update for poll votes

* prevent twice optimistic zap

* enhance client notifications with skeleton and no redudant queries

* enlarge nwc amount limits

* Disable max amount and daily limit in NWC container

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-06-12 08:34:24 -05:00

631 lines
22 KiB
JavaScript

import { useState, useEffect, useMemo } from 'react'
import { useQuery } from '@apollo/client'
import Comment, { CommentSkeleton } from './comment'
import Item from './item'
import ItemJob from './item-job'
import { NOTIFICATIONS } from '@/fragments/notifications'
import MoreFooter from './more-footer'
import Invite from './invite'
import { dayMonthYear, timeSince } from '@/lib/time'
import Link from 'next/link'
import Check from '@/svgs/check-double-line.svg'
import HandCoin from '@/svgs/hand-coin-fill.svg'
import { LOST_BLURBS, FOUND_BLURBS, UNKNOWN_LINK_REL } from '@/lib/constants'
import CowboyHatIcon from '@/svgs/cowboy.svg'
import BaldIcon from '@/svgs/bald.svg'
import { RootProvider } from './root'
import Alert from 'react-bootstrap/Alert'
import styles from './notifications.module.css'
import { useServiceWorker } from './serviceworker'
import { Checkbox, Form } from './form'
import { useRouter } from 'next/router'
import { useData } from './use-data'
import { nostrZapDetails } from '@/lib/nostr'
import Text from './text'
import NostrIcon from '@/svgs/nostr.svg'
import { numWithUnits } from '@/lib/format'
import BountyIcon from '@/svgs/bounty-bag.svg'
import { LongCountdown } from './countdown'
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
return (
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
{
(type === 'Earn' && <EarnNotification n={n} />) ||
(type === 'Revenue' && <RevenueNotification n={n} />) ||
(type === 'Invitification' && <Invitification n={n} />) ||
(type === 'InvoicePaid' && (n.invoice.nostr ? <NostrZap n={n} /> : <InvoicePaid n={n} />)) ||
(type === 'WithdrawlPaid' && <WithdrawlPaid n={n} />) ||
(type === 'Referral' && <Referral n={n} />) ||
(type === 'Streak' && <Streak n={n} />) ||
(type === 'Votification' && <Votification n={n} />) ||
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
(type === 'Mention' && <Mention n={n} />) ||
(type === 'ItemMention' && <ItemMention n={n} />) ||
(type === 'JobChanged' && <JobChanged n={n} />) ||
(type === 'Reply' && <Reply n={n} />) ||
(type === 'SubStatus' && <SubStatus n={n} />) ||
(type === 'FollowActivity' && <FollowActivity n={n} />) ||
(type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) ||
(type === 'Reminder' && <Reminder n={n} />) ||
<ClientNotification n={n} />
}
</NotificationLayout>
)
}
function ClientNotification ({ n }) {
// 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 (
([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(n.__typename) && <ClientZap n={itemN} />) ||
([ClientTypes.Reply.ERROR, ClientTypes.Reply.PENDING].includes(n.__typename) && <ClientReply n={itemN} />) ||
([ClientTypes.Bounty.ERROR, ClientTypes.Bounty.PENDING].includes(n.__typename) && <ClientBounty n={itemN} />) ||
([ClientTypes.PollVote.ERROR, ClientTypes.PollVote.PENDING].includes(n.__typename) && <ClientPollVote n={itemN} />)
)
}
function NotificationLayout ({ children, nid, href, as, fresh }) {
const router = useRouter()
if (!href) return <div className={fresh ? styles.fresh : ''}>{children}</div>
return (
<LinkToContext
className={`${fresh ? styles.fresh : ''} ${router?.query?.nid === nid ? 'outline-it' : ''}`}
onClick={async (e) => {
e.preventDefault()
nid && await router.replace({
pathname: router.pathname,
query: {
...router.query,
nid
}
}, router.asPath, { ...router.options, shallow: true })
router.push(href, as)
}}
href={href}
>
{children}
</LinkToContext>
)
}
const defaultOnClick = n => {
const type = n.__typename
if (type === 'Earn') {
let href = '/rewards/'
if (n.minSortTime !== n.sortTime) {
href += `${dayMonthYear(new Date(n.minSortTime))}/`
}
href += dayMonthYear(new Date(n.sortTime))
return { href }
}
if (type === 'Revenue') return { href: `/~${n.subName}` }
if (type === 'SubStatus') return { href: `/~${n.sub.name}` }
if (type === 'Invitification') return { href: '/invites' }
if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` }
if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` }
if (type === 'Referral') return { href: '/referrals/month' }
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)
return {
href: {
pathname: '/items/[id]',
query: { id: rootId, commentId: n.item.id }
},
as: `/items/${rootId}`
}
} else {
return {
href: {
pathname: '/items/[id]',
query: { id: n.item.id }
},
as: `/items/${n.item.id}`
}
}
}
function Streak ({ n }) {
function blurb (n) {
const index = Number(n.id) % Math.min(FOUND_BLURBS.length, LOST_BLURBS.length)
if (n.days) {
return `After ${numWithUnits(n.days, {
abbreviate: false,
unitSingular: 'day',
unitPlural: 'days'
})}, ` + LOST_BLURBS[index]
}
return FOUND_BLURBS[index]
}
return (
<div className='d-flex ms-2 py-1'>
<div style={{ fontSize: '2rem' }}>{n.days ? <BaldIcon className='fill-grey' height={40} width={40} /> : <CowboyHatIcon className='fill-grey' height={40} width={40} />}</div>
<div className='ms-1 p-1'>
<span className='fw-bold'>you {n.days ? 'lost your' : 'found a'} cowboy hat</span>
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
</div>
</div>
)
}
function EarnNotification ({ n }) {
const time = n.minSortTime === n.sortTime ? dayMonthYear(new Date(n.minSortTime)) : `${dayMonthYear(new Date(n.minSortTime))} to ${dayMonthYear(new Date(n.sortTime))}`
return (
<div className='d-flex ms-2 py-1'>
<HandCoin className='align-self-center fill-boost mx-1' width={24} height={24} style={{ flex: '0 0 24px', transform: 'rotateY(180deg)' }} />
<div className='ms-2'>
<div className='fw-bold text-boost'>
you stacked {numWithUnits(n.earnedSats, { abbreviate: false })} in rewards<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{time}</small>
</div>
{n.sources &&
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
{n.sources.posts > 0 && <span>{numWithUnits(n.sources.posts, { abbreviate: false })} for top posts</span>}
{n.sources.comments > 0 && <span>{n.sources.posts > 0 && ' \\ '}{numWithUnits(n.sources.comments, { abbreviate: false })} for top comments</span>}
{n.sources.tipPosts > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0) && ' \\ '}{numWithUnits(n.sources.tipPosts, { abbreviate: false })} for zapping top posts early</span>}
{n.sources.tipComments > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0 || n.sources.tipPosts > 0) && ' \\ '}{numWithUnits(n.sources.tipComments, { abbreviate: false })} for zapping top comments early</span>}
</div>}
<div style={{ lineHeight: '140%' }}>
SN distributes the sats it earns back to its best stackers. These sats come from <Link href='/~jobs'>jobs</Link>, boosts, posting fees, and donations. You can see the rewards pool and make a donation <Link href='/rewards'>here</Link>.
</div>
<small className='text-muted ms-1 pb-1 fw-normal'>click for details</small>
</div>
</div>
)
}
function RevenueNotification ({ n }) {
return (
<div className='d-flex ms-2 py-1'>
<BountyIcon className='align-self-center fill-success mx-1' width={24} height={24} style={{ flex: '0 0 24px' }} />
<div className='ms-2 pb-1'>
<div className='fw-bold text-success'>
you stacked {numWithUnits(n.earnedSats, { abbreviate: false })} in territory revenue<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
</div>
<div style={{ lineHeight: '140%' }}>
As the founder of territory <Link href={`/~${n.subName}`}>~{n.subName}</Link>, you receive 50% of the revenue it generates and the other 50% go to <Link href='/rewards'>rewards</Link>.
</div>
</div>
</div>
)
}
function SubStatus ({ n }) {
const dueDate = nextBillingWithGrace(n.sub)
return (
<div className={`fw-bold text-${n.sub.status === 'ACTIVE' ? 'success' : 'danger'} ms-2`}>
{n.sub.status === 'ACTIVE'
? 'your territory is active again'
: (n.sub.status === 'GRACE'
? <>your territory payment for ~{n.sub.name} is due or your territory will be archived in <LongCountdown date={dueDate} /></>
: <>your territory ~{n.sub.name} has been archived</>)}
<small className='text-muted d-block pb-1 fw-normal'>click to visit territory and pay</small>
</div>
)
}
function Invitification ({ n }) {
return (
<>
<small className='fw-bold text-secondary ms-2'>
your invite has been redeemed by
{numWithUnits(n.invite.invitees.length, {
abbreviate: false,
unitSingular: 'stacker',
unitPlural: 'stackers'
})}
</small>
<div className='ms-4 me-2 mt-1'>
<Invite
invite={n.invite} active={
!n.invite.revoked &&
!(n.invite.limit && n.invite.invitees.length >= n.invite.limit)
}
/>
</div>
</>
)
}
function NostrZap ({ n }) {
const { nostr } = n.invoice
const { npub, content, note } = nostrZapDetails(nostr)
return (
<>
<div className='fw-bold text-nostr ms-2 py-1'>
<NostrIcon width={24} height={24} className='fill-nostr me-1' />{numWithUnits(n.earnedSats)} zap from
{// eslint-disable-next-line
<Link className='mx-1 text-reset text-underline' target='_blank' href={`https://njump.me/${npub}`} rel={UNKNOWN_LINK_REL}>
{npub.slice(0, 10)}...
</Link>
}
on {note
? (
// eslint-disable-next-line
<Link className='mx-1 text-reset text-underline' target='_blank' href={`https://njump.me/${note}`} rel={UNKNOWN_LINK_REL}>
{note.slice(0, 12)}...
</Link>)
: 'nostr'}
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
{content && <small className='d-block ms-4 ps-1 mt-1 mb-1 text-muted fw-normal'><Text>{content}</Text></small>}
</div>
</>
)
}
function InvoicePaid ({ n }) {
let payerSig
if (n.invoice.lud18Data) {
const { name, identifier, email, pubkey } = n.invoice.lud18Data
const id = identifier || email || pubkey
payerSig = '- '
if (name) {
payerSig += name
if (id) payerSig += ' \\ '
}
if (id) payerSig += id
}
return (
<div className='fw-bold text-info ms-2 py-1'>
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
{n.invoice.comment &&
<small className='d-block ms-4 ps-1 mt-1 mb-1 text-muted fw-normal'>
<Text>{n.invoice.comment}</Text>
{payerSig}
</small>}
</div>
)
}
function WithdrawlPaid ({ n }) {
return (
<div className='fw-bold text-info ms-2 py-1'>
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
{n.withdrawl.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>}
</div>
)
}
function Referral ({ n }) {
return (
<small className='fw-bold text-secondary ms-2'>
someone joined via one of your referral links
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
</small>
)
}
function Votification ({ n }) {
let forwardedSats = 0
let ForwardedUsers = null
if (n.item.forwards?.length) {
forwardedSats = Math.floor(n.earnedSats * n.item.forwards.map(fwd => fwd.pct).reduce((sum, cur) => sum + cur) / 100)
ForwardedUsers = () => n.item.forwards.map((fwd, i) =>
<span key={fwd.user.name}>
<Link className='text-success' href={`/${fwd.user.name}`}>
@{fwd.user.name}
</Link>
{i !== n.item.forwards.length - 1 && ' '}
</span>)
}
return (
<>
<small className='fw-bold text-success d-inline-block ms-2 my-1' style={{ lineHeight: '1.25' }}>
your {n.item.title ? 'post' : 'reply'} stacked {numWithUnits(n.earnedSats, { abbreviate: false })}
{n.item.forwards?.length > 0 &&
<>
{' '}and forwarded {numWithUnits(forwardedSats, { abbreviate: false })} to{' '}
<ForwardedUsers />
</>}
</small>
<div>
{n.item.title
? <Item item={n.item} />
: (
<div className='pb-2'>
<RootProvider root={n.item.root}>
<Comment item={n.item} noReply includeParent clickToContext />
</RootProvider>
</div>
)}
</div>
</>
)
}
function ForwardedVotification ({ n }) {
return (
<>
<small className='fw-bold text-success d-inline-block ms-2 my-1' style={{ lineHeight: '1.25' }}>
you were forwarded {numWithUnits(n.earnedSats, { abbreviate: false })} from
</small>
<div>
{n.item.title
? <Item item={n.item} />
: (
<div className='pb-2'>
<RootProvider root={n.item.root}>
<Comment item={n.item} noReply includeParent clickToContext />
</RootProvider>
</div>
)}
</div>
</>
)
}
function Mention ({ n }) {
return (
<>
<small className='fw-bold text-info ms-2'>
you were mentioned in
</small>
<div>
{n.item.title
? <Item item={n.item} />
: (
<div className='pb-2'>
<RootProvider root={n.item.root}>
<Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying on:' : undefined} clickToContext />
</RootProvider>
</div>)}
</div>
</>
)
}
function ItemMention ({ n }) {
return (
<>
<small className='fw-bold text-info ms-2'>
your item was mentioned in
</small>
<div>
{n.item?.title
? <Item item={n.item} />
: (
<div className='pb-2'>
<RootProvider root={n.item.root}>
<Comment item={n.item} noReply includeParent rootText='replying on:' clickToContext />
</RootProvider>
</div>)}
</div>
</>
)
}
function JobChanged ({ n }) {
return (
<>
<small className={`fw-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ms-1`}>
{n.item.status === 'ACTIVE'
? 'your job is active again'
: (n.item.status === 'NOSATS'
? 'your job promotion ran out of sats'
: 'your job has been stopped')}
</small>
<ItemJob item={n.item} />
</>
)
}
function Reply ({ n }) {
return (
<div className='py-2'>
{n.item.title
? <Item item={n.item} />
: (
<div className='pb-2'>
<RootProvider root={n.item.root}>
<Comment item={n.item} noReply includeParent clickToContext rootText='replying on:' />
</RootProvider>
</div>
)}
</div>
)
}
function FollowActivity ({ n }) {
return (
<>
<small className='fw-bold text-info ms-2'>
a stacker you subscribe to {n.item.parentId ? 'commented' : 'posted'}
</small>
{n.item.title
? <div className='ms-2'><Item item={n.item} /></div>
: (
<div className='pb-2'>
<RootProvider root={n.item.root}>
<Comment item={n.item} noReply includeParent clickToContext rootText='replying on:' />
</RootProvider>
</div>
)}
</>
)
}
function TerritoryPost ({ n }) {
return (
<>
<small className='fw-bold text-info ms-2'>
new post in ~{n.item.sub.name}
</small>
<div>
<Item item={n.item} />
</div>
</>
)
}
function TerritoryTransfer ({ n }) {
return (
<>
<div className='fw-bold text-info ms-2'>
~{n.sub.name} was transferred to you
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
</div>
</>
)
}
function Reminder ({ n }) {
return (
<>
<small className='fw-bold text-info ms-2'>you asked to be reminded of this {n.item.title ? 'post' : 'comment'}</small>
{n.item.title
? <div className='ms-2'><Item item={n.item} /></div>
: (
<div className='pb-2'>
<RootProvider root={n.item.root}>
<Comment item={n.item} noReply includeParent clickToContext rootText='replying on:' />
</RootProvider>
</div>
)}
</>
)
}
export function NotificationAlert () {
const [showAlert, setShowAlert] = useState(false)
const [hasSubscription, setHasSubscription] = useState(false)
const [error, setError] = useState(null)
const [supported, setSupported] = useState(false)
const sw = useServiceWorker()
useEffect(() => {
const isSupported = sw.support.serviceWorker && sw.support.pushManager && sw.support.notification
if (isSupported) {
const isDefaultPermission = sw.permission.notification === 'default'
setShowAlert(isDefaultPermission && !window.localStorage.getItem('hideNotifyPrompt'))
sw.registration?.pushManager.getSubscription().then(subscription => setHasSubscription(!!subscription))
setSupported(true)
}
}, [sw])
const close = () => {
window.localStorage.setItem('hideNotifyPrompt', 'yep')
setShowAlert(false)
}
return (
error
? (
<Alert variant='danger' dismissible onClose={() => setError(null)}>
<span>{error.toString()}</span>
</Alert>
)
: showAlert
? (
<Alert variant='info' dismissible onClose={close}>
<span className='align-middle'>Enable push notifications?</span>
<button
className={`${styles.alertBtn} mx-1`}
onClick={async () => {
await sw.requestNotificationPermission()
.then(close)
.catch(setError)
}}
>Yes
</button>
<button className={styles.alertBtn} onClick={close}>No</button>
</Alert>
)
: (
<Form className={`d-flex justify-content-end ${supported ? 'visible' : 'invisible'}`} initial={{ pushNotify: hasSubscription }}>
<Checkbox
name='pushNotify' label={<span className='text-muted'>push notifications</span>}
groupClassName={`${styles.subFormGroup} mb-1 me-sm-3 me-0`}
inline checked={hasSubscription} handleChange={async () => {
await sw.togglePushSubscription().catch(setError)
}}
/>
</Form>
)
)
}
const nid = n => n.__typename + n.id + n.sortTime
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 {}
// make sure we're using the oldest lastChecked we've seen
const retDat = { ...dat.notifications }
if (ssrData?.notifications?.lastChecked < retDat.lastChecked) {
retDat.lastChecked = ssrData.notifications.lastChecked
}
return retDat
}, [dat])
useEffect(() => {
if (lastChecked && !router?.query?.checkedAt) {
router.replace({
pathname: router.pathname,
query: {
...router.query,
nodata: true, // make sure nodata is set so we don't fetch on back/forward
checkedAt: lastChecked
}
}, router.asPath, { ...router.options, shallow: true })
}
}, [router?.query?.checkedAt, lastChecked])
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 =>
<Notification
n={n} key={nid(n)}
fresh={new Date(n.sortTime) > new Date(router?.query?.checkedAt ?? lastChecked)}
/>)}
<MoreFooter cursor={cursor} count={notifications?.length} fetchMore={fetchMore} Skeleton={CommentsFlatSkeleton} noMoreText='NO MORE' />
</>
)
}
function CommentsFlatSkeleton () {
const comments = new Array(21).fill(null)
return (
<div>
{comments.map((_, i) => (
<CommentSkeleton key={i} skeletonChildren={0} />
))}
</div>
)
}