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 UserAdd from '@/svgs/user-add-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 GunIcon from '@/svgs/revolver.svg' import HorseIcon from '@/svgs/horse.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' import { useToast } from './toast' import classNames from 'classnames' import HolsterIcon from '@/svgs/holster.svg' import SaddleIcon from '@/svgs/saddle.svg' 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' && ) || (type === 'ReferralReward' && ) } ) } function NotificationLayout ({ children, type, 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} ) } function NoteHeader ({ color, children, big }) { return (
{children}
) } function NoteItem ({ item, ...props }) { return (
{item.title ? : ( )}
) } 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 === 'ReferralReward') 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 type = n.type ?? 'COWBOY_HAT' const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length) if (n.days) { return `After ${numWithUnits(n.days, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}, ` + LOST_BLURBS[type][index] } return FOUND_BLURBS[type][index] } const Icon = n.days ? n.type === 'GUN' ? HolsterIcon : n.type === 'HORSE' ? SaddleIcon : BaldIcon : n.type === 'GUN' ? GunIcon : n.type === 'HORSE' ? HorseIcon : CowboyHatIcon return (
you {n.days ? 'lost your' : 'found a'} {n.type.toLowerCase().replace('_', ' ')}
{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 to top stackers like you daily. The top stackers make the top posts and comments or zap the top posts and comments early and generously. View the rewards pool and make a donation here.
click for details
) } function ReferralReward ({ n }) { return (
you stacked {numWithUnits(n.earnedSats, { abbreviate: false })} in referral rewards{dayMonthYear(new Date(n.sortTime))} {n.sources &&
{n.sources.forever > 0 && {numWithUnits(n.sources.forever, { abbreviate: false })} for stackers joining because of you} {n.sources.oneDay > 0 && {n.sources.forever > 0 && ' \\ '}{numWithUnits(n.sources.oneDay, { abbreviate: false })} for stackers referred to content by you today}
}
SN gives referral rewards to stackers like you for referring the top stackers daily. You refer stackers when they visit your posts, comments, profile, territory, or if they visit SN through your referral links.
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 70% of the post, comment, boost, and zap fees. The other 30% 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.root?.bounty === invoice.satsRequested && invoice.item.root?.mine ? 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 toaster = useToast() const actRetry = useActRetry({ invoice }) const retryCreateItem = useRetryCreateItem({ id: invoice.item?.id }) const retryPollVote = usePollVote({ query: RETRY_PAID_ACTION, itemId: invoice.item?.id }) const [disableRetry, setDisableRetry] = useState(false) // 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 { if (invoice.actionType === 'ZAP') { if (invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine) { actionString = 'bounty payment' } else { actionString = 'zap' } } else if (invoice.actionType === 'DOWN_ZAP') { actionString = 'downzap' } else if (invoice.actionType === 'BOOST') { actionString = 'boost' } actionString = `${actionString} on ${itemType} ` retry = actRetry; ({ id: invoiceId, actionState: invoiceActionState } = invoice.itemAct.invoice) } let colorClass = 'info' switch (invoiceActionState) { case 'FAILED': actionString += 'failed' colorClass = 'warning' break case 'PAID': actionString += 'paid' colorClass = 'success' break default: actionString += 'pending' } return (
{actionString} {numWithUnits(invoice.satsRequested)} {timeSince(new Date(sortTime))}
) } function WithdrawlPaid ({ n }) { return (
{numWithUnits(n.earnedSats + n.withdrawl.satsFeePaid, { abbreviate: false, unitSingular: 'sat was ', unitPlural: 'sats were ' })} {n.withdrawl.p2p || n.withdrawl.autoWithdraw ? 'sent to your attached wallet' : 'withdrawn from your account'} {timeSince(new Date(n.sortTime))} {(n.withdrawl.p2p && p2p) || (n.withdrawl.autoWithdraw && autowithdraw)}
) } function Referral ({ n }) { return ( someone joined SN because of you {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{' '} } ) } function ForwardedVotification ({ n }) { return ( <> you were forwarded {numWithUnits(n.earnedSats, { abbreviate: false })} from ) } function Mention ({ n }) { return ( <> you were mentioned in ) } function ItemMention ({ n }) { return ( <> your item was mentioned in ) } 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 } function FollowActivity ({ n }) { return ( <> a stacker you subscribe to {n.item.parentId ? 'commented' : 'posted'} ) } 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'} ) } 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) => ( ))}
) }