561 lines
19 KiB
JavaScript
561 lines
19 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 { ignoreClick } from '@/lib/clicks'
|
|
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'
|
|
|
|
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 === '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} />)
|
|
}
|
|
</NotificationLayout>
|
|
)
|
|
}
|
|
|
|
function NotificationLayout ({ children, nid, href, as, fresh }) {
|
|
const router = useRouter()
|
|
if (!href) return <div className={fresh ? styles.fresh : ''}>{children}</div>
|
|
return (
|
|
<div
|
|
className={
|
|
`clickToContext ${fresh ? styles.fresh : ''} ${router?.query?.nid === nid ? 'outline-it' : ''}`
|
|
}
|
|
onClick={async (e) => {
|
|
if (ignoreClick(e)) return
|
|
nid && await router.replace({
|
|
pathname: router.pathname,
|
|
query: {
|
|
...router.query,
|
|
nid
|
|
}
|
|
}, router.asPath, { ...router.options, shallow: true })
|
|
router.push(href, as)
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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}` }
|
|
|
|
// 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://snort.social/p/${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://snort.social/e/${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>
|
|
</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 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>
|
|
</>
|
|
)
|
|
}
|
|
|
|
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: { notifications, lastChecked, cursor } } = useMemo(() => {
|
|
return dat || { notifications: {} }
|
|
}, [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, lastChecked])
|
|
|
|
if (!dat) return <CommentsFlatSkeleton />
|
|
|
|
return (
|
|
<>
|
|
{notifications.map(n =>
|
|
<Notification
|
|
n={n} key={nid(n)}
|
|
fresh={new Date(n.sortTime) > new Date(router?.query?.checkedAt)}
|
|
/>)}
|
|
<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>
|
|
)
|
|
}
|