import { useState, useEffect, useMemo } from 'react' import { gql, 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, Button } from 'react-bootstrap' import { useAct } from './item-act' import { RETRY_PAID_ACTION } from '@/fragments/paidAction' import { usePollVote } from './poll' import { paidActionCacheMods } from './use-paid-mutation' import { useRetryCreateItem } from './use-item-submit' import { payBountyCacheMods } from './pay-bounty' function Notification ({ n, fresh }) { const type = n.__typename return ( { (type === 'Earn' && ) || (type === 'Revenue' && ) || (type === 'Invitification' && ) || (type === 'InvoicePaid' && (n.invoice.nostr ? : )) || (type === 'WithdrawlPaid' && ) || (type === 'Referral' && ) || (type === 'Streak' && ) || (type === 'Votification' && ) || (type === 'ForwardedVotification' && ) || (type === 'Mention' && ) || (type === 'ItemMention' && ) || (type === 'JobChanged' && ) || (type === 'Reply' && ) || (type === 'SubStatus' && ) || (type === 'FollowActivity' && ) || (type === 'TerritoryPost' && ) || (type === 'TerritoryTransfer' && ) || (type === 'Reminder' && ) || (type === 'Invoicification' && ) } ) } function NotificationLayout ({ children, nid, href, as, fresh }) { const router = useRouter() if (!href) return
{children}
return ( { 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} ) } 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 } } const itemLink = item => { if (!item) return {} if (item.title) { return { href: { pathname: '/items/[id]', query: { id: item.id } }, as: `/items/${item.id}` } } else { const rootId = commentSubTreeRootId(item) return { href: { pathname: '/items/[id]', query: { id: rootId, commentId: item.id } }, as: `/items/${rootId}` } } } 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 === 'Invoicification') return itemLink(n.invoice.item) 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 return itemLink(n.item) } 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 (
{n.days ? : }
you {n.days ? 'lost your' : 'found a'} cowboy hat
{blurb(n)}
) } 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 (
you stacked {numWithUnits(n.earnedSats, { abbreviate: false })} in rewards{time}
{n.sources &&
{n.sources.posts > 0 && {numWithUnits(n.sources.posts, { abbreviate: false })} for top posts} {n.sources.comments > 0 && {n.sources.posts > 0 && ' \\ '}{numWithUnits(n.sources.comments, { abbreviate: false })} for top comments} {n.sources.tipPosts > 0 && {(n.sources.comments > 0 || n.sources.posts > 0) && ' \\ '}{numWithUnits(n.sources.tipPosts, { abbreviate: false })} for zapping top posts early} {n.sources.tipComments > 0 && {(n.sources.comments > 0 || n.sources.posts > 0 || n.sources.tipPosts > 0) && ' \\ '}{numWithUnits(n.sources.tipComments, { abbreviate: false })} for zapping top comments early}
}
SN distributes the sats it earns back to its best stackers. These sats come from jobs, boosts, posting fees, and donations. You can see the rewards pool and make a donation here.
click for details
) } function RevenueNotification ({ n }) { return (
you stacked {numWithUnits(n.earnedSats, { abbreviate: false })} in territory revenue{timeSince(new Date(n.sortTime))}
As the founder of territory ~{n.subName}, you receive 50% of the revenue it generates and the other 50% go to rewards.
) } function SubStatus ({ n }) { const dueDate = nextBillingWithGrace(n.sub) return (
{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 : <>your territory ~{n.sub.name} has been archived)} click to visit territory and pay
) } function Invitification ({ n }) { return ( <> your invite has been redeemed by {numWithUnits(n.invite.invitees.length, { abbreviate: false, unitSingular: 'stacker', unitPlural: 'stackers' })}
= n.invite.limit) } />
) } function NostrZap ({ n }) { const { nostr } = n.invoice const { npub, content, note } = nostrZapDetails(nostr) return ( <>
{numWithUnits(n.earnedSats)} zap from {// eslint-disable-next-line {npub.slice(0, 10)}... } on {note ? ( // eslint-disable-next-line {note.slice(0, 12)}... ) : 'nostr'} {timeSince(new Date(n.sortTime))} {content && {content}}
) } 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 (
{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account {timeSince(new Date(n.sortTime))} {n.invoice.comment && {n.invoice.comment} {payerSig} }
) } function useActRetry ({ invoice }) { const bountyCacheMods = invoice.item?.bounty ? payBountyCacheMods() : {} return useAct({ query: RETRY_PAID_ACTION, onPayError: (e, cache, { data }) => { paidActionCacheMods?.onPayError?.(e, cache, { data }) bountyCacheMods?.onPayError?.(e, cache, { data }) }, onPaid: (cache, { data }) => { paidActionCacheMods?.onPaid?.(cache, { data }) bountyCacheMods?.onPaid?.(cache, { data }) }, update: (cache, { data }) => { const response = Object.values(data)[0] if (!response?.invoice) return cache.modify({ id: `ItemAct:${invoice.itemAct?.id}`, fields: { // this is a bit of a hack just to update the reference to the new invoice invoice: () => cache.writeFragment({ id: `Invoice:${response.invoice.id}`, fragment: gql` fragment _ on Invoice { bolt11 } `, data: { bolt11: response.invoice.bolt11 } }) } }) paidActionCacheMods?.update?.(cache, { data }) bountyCacheMods?.update?.(cache, { data }) } }) } function Invoicification ({ n: { invoice, sortTime } }) { const actRetry = useActRetry({ invoice }) const retryCreateItem = useRetryCreateItem({ id: invoice.item?.id }) const retryPollVote = usePollVote({ query: RETRY_PAID_ACTION, itemId: invoice.item?.id }) // XXX if we navigate to an invoice after it is retried in notifications // the cache will clear invoice.item and will error on window.back // alternatively, we could/should // 1. update the notification cache to include the new invoice // 2. make item has-many invoices if (!invoice.item) return null let retry let actionString let invoiceId let invoiceActionState const itemType = invoice.item.title ? 'post' : 'comment' if (invoice.actionType === 'ITEM_CREATE') { actionString = `${itemType} create ` retry = retryCreateItem; ({ id: invoiceId, actionState: invoiceActionState } = invoice.item.invoice) } else if (invoice.actionType === 'POLL_VOTE') { actionString = 'poll vote ' retry = retryPollVote invoiceId = invoice.item.poll?.meInvoiceId invoiceActionState = invoice.item.poll?.meInvoiceActionState } else { actionString = `${invoice.actionType === 'ZAP' ? invoice.item.root?.bounty ? 'bounty payment' : 'zap' : 'downzap'} on ${itemType} ` retry = actRetry; ({ id: invoiceId, actionState: invoiceActionState } = invoice.itemAct.invoice) } let colorClass = 'text-info' switch (invoiceActionState) { case 'FAILED': actionString += 'failed' colorClass = 'text-warning' break case 'PAID': actionString += 'paid' colorClass = 'text-success' break default: actionString += 'pending' } return (
{actionString} {numWithUnits(invoice.satsRequested)} {timeSince(new Date(sortTime))}
{invoice.item.title ? : (
)}
) } function WithdrawlPaid ({ n }) { return (
{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account {timeSince(new Date(n.sortTime))} {n.withdrawl.autoWithdraw && autowithdraw}
) } function Referral ({ n }) { return ( someone joined via one of your referral links {timeSince(new Date(n.sortTime))} ) } 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) => @{fwd.user.name} {i !== n.item.forwards.length - 1 && ' '} ) } return ( <> your {n.item.title ? 'post' : 'reply'} stacked {numWithUnits(n.earnedSats, { abbreviate: false })} {n.item.forwards?.length > 0 && <> {' '}and forwarded {numWithUnits(forwardedSats, { abbreviate: false })} to{' '} }
{n.item.title ? : (
)}
) } function ForwardedVotification ({ n }) { return ( <> you were forwarded {numWithUnits(n.earnedSats, { abbreviate: false })} from
{n.item.title ? : (
)}
) } function Mention ({ n }) { return ( <> you were mentioned in
{n.item.title ? : (
)}
) } function ItemMention ({ n }) { return ( <> your item was mentioned in {n.item?.title ?
: (
)} ) } function JobChanged ({ n }) { return ( <> {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')} ) } function Reply ({ n }) { return (
{n.item.title ? : (
)}
) } function FollowActivity ({ n }) { return ( <> a stacker you subscribe to {n.item.parentId ? 'commented' : 'posted'} {n.item.title ?
: (
)} ) } function TerritoryPost ({ n }) { return ( <> new post in ~{n.item.sub.name}
) } function TerritoryTransfer ({ n }) { return ( <>
~{n.sub.name} was transferred to you {timeSince(new Date(n.sortTime))}
) } function Reminder ({ n }) { return ( <> you asked to be reminded of this {n.item.title ? 'post' : 'comment'} {n.item.title ?
: (
)} ) } 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 ? ( setError(null)}> {error.toString()} ) : showAlert ? ( Enable push notifications? ) : (
push notifications} groupClassName={`${styles.subFormGroup} mb-1 me-sm-3 me-0`} inline checked={hasSubscription} handleChange={async () => { await sw.togglePushSubscription().catch(setError) }} /> ) ) } 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, 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 return ( <> {notifications.map(n => new Date(router?.query?.checkedAt ?? lastChecked)} />)} ) } function CommentsFlatSkeleton () { const comments = new Array(21).fill(null) return (
{comments.map((_, i) => ( ))}
) }