stacker.news/components/notifications.js

384 lines
14 KiB
JavaScript
Raw Normal View History

import { useState, useCallback, useContext, useEffect, createContext } from 'react'
import { useQuery } from '@apollo/client'
2021-08-17 18:15:24 +00:00
import Comment, { CommentSkeleton } from './comment'
2022-07-21 22:55:05 +00:00
import Item from './item'
import ItemJob from './item-job'
2021-08-17 18:15:24 +00:00
import { NOTIFICATIONS } from '../fragments/notifications'
2021-08-17 23:59:22 +00:00
import { useRouter } from 'next/router'
2021-09-30 15:46:58 +00:00
import MoreFooter from './more-footer'
2022-01-19 21:02:38 +00:00
import Invite from './invite'
import { ignoreClick } from '../lib/clicks'
2022-03-31 16:49:35 +00:00
import { timeSince } from '../lib/time'
2022-03-17 20:13:19 +00:00
import Link from 'next/link'
2022-03-23 18:54:39 +00:00
import Check from '../svgs/check-double-line.svg'
2022-03-23 21:45:36 +00:00
import HandCoin from '../svgs/hand-coin-fill.svg'
2022-05-17 22:09:15 +00:00
import { COMMENT_DEPTH_LIMIT } from '../lib/constants'
2023-02-01 14:44:35 +00:00
import CowboyHatIcon from '../svgs/cowboy.svg'
import BaldIcon from '../svgs/bald.svg'
2023-05-06 21:51:17 +00:00
import { RootProvider } from './root'
import { Alert } from 'react-bootstrap'
import styles from './notifications.module.css'
2021-08-17 18:15:24 +00:00
function Notification ({ n }) {
switch (n.__typename) {
case 'Earn': return <EarnNotification n={n} />
case 'Invitification': return <Invitification n={n} />
case 'InvoicePaid': return <InvoicePaid n={n} />
case 'Referral': return <Referral n={n} />
case 'Streak': return <Streak n={n} />
case 'Votification': return <Votification n={n} />
case 'Mention': return <Mention n={n} />
case 'JobChanged': return <JobChanged n={n} />
case 'Reply': return <Reply n={n} />
}
console.error('__typename not supported:', n.__typename)
return null
}
function NotificationLayout ({ children, onClick }) {
return (
<div
className='clickToContext' onClick={(e) => {
if (ignoreClick(e)) return
onClick?.(e)
}}
>
{children}
2023-02-01 14:44:35 +00:00
</div>
)
}
const defaultOnClick = (n, router) => () => {
if (!n.item.title) {
if (n.item.path.split('.').length > COMMENT_DEPTH_LIMIT + 1) {
router.push({
pathname: '/items/[id]',
query: { id: n.item.parentId, commentId: n.item.id }
}, `/items/${n.item.parentId}`)
} else {
router.push({
pathname: '/items/[id]',
query: { id: n.item.root.id, commentId: n.item.id }
}, `/items/${n.item.root.id}`)
}
} else {
router.push({
pathname: '/items/[id]',
query: { id: n.item.id }
}, `/items/${n.item.id}`)
}
}
2023-02-01 14:44:35 +00:00
function Streak ({ n }) {
function blurb (n) {
const index = Number(n.id) % 6
const FOUND_BLURBS = [
'The harsh frontier is no place for the unprepared. This hat will protect you from the sun, dust, and other elements Mother Nature throws your way.',
'A cowboy is nothing without a cowboy hat. Take good care of it, and it will protect you from the sun, dust, and other elements on your journey.',
"This is not just a hat, it's a matter of survival. Take care of this essential tool, and it will shield you from the scorching sun and the elements.",
"A cowboy hat isn't just a fashion statement. It's your last defense against the unforgiving elements of the Wild West. Hang onto it tight.",
"A good cowboy hat is worth its weight in gold, shielding you from the sun, wind, and dust of the western frontier. Don't lose it.",
'Your cowboy hat is the key to your survival in the wild west. Treat it with respect and it will protect you from the elements.'
]
const LOST_BLURBS = [
'your cowboy hat was taken by the wind storm that blew in from the west. No worries, a true cowboy always finds another hat.',
"you left your trusty cowboy hat in the saloon before leaving town. You'll need a replacement for the long journey west.",
'you lost your cowboy hat in a wild shoot-out on the outskirts of town. Tough luck, tIme to start searching for another one.',
'you ran out of food and had to trade your hat for supplies. Better start looking for another hat.',
"your hat was stolen by a mischievous prairie dog. You won't catch the dog, but you can always find another hat.",
'you lost your hat while crossing the river on your journey west. Maybe you can find a replacement hat in the next town.'
]
if (n.days) {
return `After ${n.days} days, ` + LOST_BLURBS[index]
}
return FOUND_BLURBS[index]
}
return (
<div className='d-flex font-weight-bold ml-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='ml-1 p-1'>
you {n.days ? 'lost your' : 'found a'} cowboy hat
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
</div>
2021-08-20 00:13:32 +00:00
</div>
)
}
function EarnNotification ({ n }) {
return (
<NotificationLayout>
<div className='d-flex'>
<HandCoin className='align-self-center fill-boost mx-1' width={24} height={24} style={{ flex: '0 0 24px', transform: 'rotateY(180deg)' }} />
<div className='ml-2'>
<div className='font-weight-bold text-boost'>
you stacked {n.earnedSats} sats in rewards<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
</div>
{n.sources &&
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
{n.sources.posts > 0 && <span>{n.sources.posts} sats for top posts</span>}
{n.sources.comments > 0 && <span>{n.sources.posts > 0 && ' \\ '}{n.sources.comments} sats for top comments</span>}
{n.sources.tips > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0) && ' \\ '}{n.sources.tips} sats for tipping top content early</span>}
</div>}
<div className='pb-1' style={{ lineHeight: '140%' }}>
SN distributes the sats it earns back to its best users daily. These sats come from <Link href='/~jobs' passHref><a>jobs</a></Link>, boosts, posting fees, and donations. You can see the daily rewards pool and make a donation <Link href='/rewards' passHref><a>here</a></Link>.
</div>
</div>
</div>
</NotificationLayout>
)
}
function Invitification ({ n }) {
const router = useRouter()
return (
<NotificationLayout onClick={() => router.push('/invites')}>
<small className='font-weight-bold text-secondary ml-2'>
your invite has been redeemed by {n.invite.invitees.length} users
</small>
<div className='ml-4 mr-2 mt-1'>
<Invite
invite={n.invite} active={
!n.invite.revoked &&
!(n.invite.limit && n.invite.invitees.length >= n.invite.limit)
}
/>
</div>
</NotificationLayout>
)
}
function InvoicePaid ({ n }) {
const router = useRouter()
return (
<NotificationLayout onClick={() => router.push(`/invoices/${n.invoice.id}`)}>
<div className='font-weight-bold text-info ml-2 py-1'>
<Check className='fill-info mr-1' />{n.earnedSats} sats were deposited in your account
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
</div>
</NotificationLayout>
)
}
function Referral ({ n }) {
return (
<NotificationLayout>
<small className='font-weight-bold text-secondary ml-2'>
someone joined via one of your <Link href='/referrals/month' passHref><a className='text-reset'>referral links</a></Link>
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
</small>
</NotificationLayout>
)
}
function Votification ({ n }) {
const router = useRouter()
return (
<NotificationLayout onClick={defaultOnClick(n, router)}>
<small className='font-weight-bold text-success ml-2'>
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
</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>
</NotificationLayout>
)
}
function Mention ({ n }) {
const router = useRouter()
return (
<NotificationLayout onClick={defaultOnClick(n, router)}>
<small className='font-weight-bold text-info ml-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>
</NotificationLayout>
)
}
function JobChanged ({ n }) {
const router = useRouter()
return (
<NotificationLayout onClick={defaultOnClick(n, router)}>
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-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} />
</NotificationLayout>
)
}
function Reply ({ n }) {
const router = useRouter()
return (
<NotificationLayout onClick={defaultOnClick(n, router)} rootText='replying on:'>
<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>
</NotificationLayout>
)
}
function NotificationAlert () {
const [showAlert, setShowAlert] = useState(false)
const pushNotify = useNotification()
useEffect(() => {
// basically, we only want to show the alert if the user hasn't interacted with
// either opt-in of the double opt-in
setShowAlert(pushNotify.isDefault && !localStorage.getItem('hideNotifyPrompt'))
}, [pushNotify])
const close = () => {
localStorage.setItem('hideNotifyPrompt', 'yep')
setShowAlert(false)
}
return (
showAlert
? (
<Alert variant='success' dismissible onClose={close}>
<span className='align-middle'>Enable push notifications?</span>
<button
className={`${styles.alertBtn} mx-1`}
onClick={() => {
pushNotify.requestPermission()
close()
}}
>Yes
</button>
<button className={`${styles.alertBtn}`} onClick={close}>No</button>
</Alert>
)
: null
)
}
2022-04-24 16:13:07 +00:00
export default function Notifications ({ notifications, earn, cursor, lastChecked, variables }) {
2021-10-26 20:49:37 +00:00
const { data, fetchMore } = useQuery(NOTIFICATIONS, { variables })
2021-08-20 00:13:32 +00:00
2021-09-30 15:46:58 +00:00
if (data) {
2022-04-24 16:13:07 +00:00
({ notifications: { notifications, earn, cursor } } = data)
2021-09-30 15:46:58 +00:00
}
2021-08-20 00:13:32 +00:00
const [fresh, old] =
notifications.reduce((result, n) => {
result[new Date(n.sortTime).getTime() > lastChecked ? 0 : 1].push(n)
return result
},
[[], []])
2021-08-17 23:59:22 +00:00
2021-08-17 18:15:24 +00:00
return (
<>
<NotificationAlert />
{/* XXX we shouldn't use the index but we don't have a unique id in this union yet */}
2021-11-04 18:22:03 +00:00
<div className='fresh'>
2022-04-24 16:13:07 +00:00
{earn && <Notification n={earn} key='earn' />}
2021-08-20 00:13:32 +00:00
{fresh.map((n, i) => (
<Notification n={n} key={i} />
))}
</div>
{old.map((n, i) => (
<Notification n={n} key={i} />
2021-08-17 18:15:24 +00:00
))}
2021-09-30 15:46:58 +00:00
<MoreFooter cursor={cursor} fetchMore={fetchMore} Skeleton={CommentsFlatSkeleton} />
2021-08-17 18:15:24 +00:00
</>
)
}
function CommentsFlatSkeleton () {
const comments = new Array(21).fill(null)
return (
<div>{comments.map((_, i) => (
<CommentSkeleton key={i} skeletonChildren={0} />
))}
</div>
)
}
const NotificationContext = createContext({})
export const NotificationProvider = ({ children }) => {
const isBrowser = typeof window !== 'undefined'
const [isSupported] = useState(isBrowser ? 'Notification' in window : false)
const [permission, setPermission_] = useState(
isSupported
? window.Notification.permission === 'granted'
// if permission was granted, we need to check if user has withdrawn permission using the settings
// since requestPermission only works once
? localStorage.getItem('notify-permission') ?? window.Notification.permission
: window.Notification.permission
: 'unsupported')
const isDefault = permission === 'default'
const isGranted = permission === 'granted'
const isDenied = permission === 'denied'
const isWithdrawn = permission === 'withdrawn'
const show_ = (title, options) => {
const icon = '/android-chrome-24x24.png'
return new window.Notification(title, { icon, ...options })
}
const show = useCallback((...args) => {
if (!isGranted) return
show_(...args)
}, [isGranted])
const setPermission = useCallback((perm) => {
localStorage.setItem('notify-permission', perm)
setPermission_(perm)
}, [])
const requestPermission = useCallback((cb) => {
window.Notification.requestPermission().then(result => {
setPermission(window.Notification.permission)
if (result === 'granted') show_('Stacker News notifications enabled')
cb?.(result)
})
}, [])
const withdrawPermission = useCallback(() => isGranted ? setPermission('withdrawn') : null, [isGranted])
const ctx = { isBrowser, isSupported, isDefault, isGranted, isDenied, isWithdrawn, requestPermission, withdrawPermission, show }
return <NotificationContext.Provider value={ctx}>{children}</NotificationContext.Provider>
}
export function useNotification () {
return useContext(NotificationContext)
}