merge github master
This commit is contained in:
		
						commit
						3068252adf
					
				@ -21,6 +21,11 @@ LNAUTH_URL=<YOUR PUBLIC TUNNEL TO LOCALHOST, e.g. NGROK>
 | 
			
		||||
# slashtags
 | 
			
		||||
SLASHTAGS_SECRET=
 | 
			
		||||
 | 
			
		||||
# VAPID for Web Push
 | 
			
		||||
VAPID_MAILTO=
 | 
			
		||||
NEXT_PUBLIC_VAPID_PUBKEY=
 | 
			
		||||
VAPID_PRIVKEY=
 | 
			
		||||
 | 
			
		||||
#######################################################
 | 
			
		||||
# LND / OPTIONAL                                      #
 | 
			
		||||
# if you want to work with payments you'll need these #
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										35
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,35 @@
 | 
			
		||||
---
 | 
			
		||||
name: Bug report
 | 
			
		||||
about: Report a problem
 | 
			
		||||
title: ''
 | 
			
		||||
labels: bug
 | 
			
		||||
assignees: ''
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
*Note: this template is meant to help you report the bug so that we can fix it faster, ie not all of these sections are required*
 | 
			
		||||
 | 
			
		||||
**Description**
 | 
			
		||||
A clear and concise description of what the bug is.
 | 
			
		||||
 | 
			
		||||
**Steps to Reproduce**
 | 
			
		||||
A clear and concise way we might be able to reproduce the bug.
 | 
			
		||||
 | 
			
		||||
**Expected behavior**
 | 
			
		||||
A clear and concise description of what you expected to happen.
 | 
			
		||||
 | 
			
		||||
**Screenshots**
 | 
			
		||||
If applicable, add screenshots to help explain your problem.
 | 
			
		||||
 | 
			
		||||
**Logs**
 | 
			
		||||
If applicable, add your browsers console logs.
 | 
			
		||||
 | 
			
		||||
**Environment:**
 | 
			
		||||
If you only experience the issue on certain devices or browsers, provide that info.
 | 
			
		||||
 - Device: [e.g. iPhone6]
 | 
			
		||||
 - OS: [e.g. iOS]
 | 
			
		||||
 - Browser [e.g. chrome, safari]
 | 
			
		||||
 - Version [e.g. 22]
 | 
			
		||||
 | 
			
		||||
**Additional context**
 | 
			
		||||
Add any other context about the problem here.
 | 
			
		||||
							
								
								
									
										20
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
---
 | 
			
		||||
name: Feature request
 | 
			
		||||
about: Suggest a feature
 | 
			
		||||
title: ''
 | 
			
		||||
labels: feature
 | 
			
		||||
assignees: ''
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
**Is your feature request related to a problem? Please describe.**
 | 
			
		||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
 | 
			
		||||
 | 
			
		||||
**Describe the solution you'd like**
 | 
			
		||||
A clear and concise description of what you want to happen.
 | 
			
		||||
 | 
			
		||||
**Describe alternatives you've considered**
 | 
			
		||||
A clear and concise description of any alternative solutions or features you've considered.
 | 
			
		||||
 | 
			
		||||
**Additional context**
 | 
			
		||||
Add any other context or screenshots about the feature request here.
 | 
			
		||||
							
								
								
									
										13
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										13
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -42,10 +42,9 @@ envbak
 | 
			
		||||
!.elasticbeanstalk/*.cfg.yml
 | 
			
		||||
!.elasticbeanstalk/*.global.yml
 | 
			
		||||
 | 
			
		||||
# auto-generated files by next-pwa / workbox
 | 
			
		||||
public/sw.js
 | 
			
		||||
public/sw.js.map
 | 
			
		||||
public/workbox-*.js
 | 
			
		||||
public/workbox-*.js.map
 | 
			
		||||
public/worker-*.js
 | 
			
		||||
public/fallback-*.js
 | 
			
		||||
# service worker
 | 
			
		||||
public/sw.js*
 | 
			
		||||
sw/precache-manifest.json
 | 
			
		||||
public/workbox-*.js*
 | 
			
		||||
public/*-development.js
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ import { msatsToSats } from '../../lib/format'
 | 
			
		||||
import { parse } from 'tldts'
 | 
			
		||||
import uu from 'url-unshort'
 | 
			
		||||
import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
 | 
			
		||||
import { sendUserNotification } from '../webPush'
 | 
			
		||||
 | 
			
		||||
async function comments (me, models, id, sort) {
 | 
			
		||||
  let orderBy
 | 
			
		||||
@ -893,7 +894,21 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    createComment: async (parent, data, { me, models }) => {
 | 
			
		||||
      await ssValidate(commentSchema, data)
 | 
			
		||||
      return await createItem(parent, data, { me, models })
 | 
			
		||||
      const item = await createItem(parent, data, { me, models })
 | 
			
		||||
 | 
			
		||||
      const parents = await models.$queryRaw(
 | 
			
		||||
        'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2',
 | 
			
		||||
        Number(item.parentId), Number(me.id))
 | 
			
		||||
      Promise.allSettled(
 | 
			
		||||
        parents.map(({ userId }) => sendUserNotification(userId, {
 | 
			
		||||
          title: 'you have a new reply',
 | 
			
		||||
          body: data.text,
 | 
			
		||||
          data: { url: `/items/${item.id}` },
 | 
			
		||||
          tag: 'REPLY'
 | 
			
		||||
        }))
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      return item
 | 
			
		||||
    },
 | 
			
		||||
    updateComment: async (parent, { id, ...data }, { me, models }) => {
 | 
			
		||||
      await ssValidate(commentSchema, data)
 | 
			
		||||
@ -929,6 +944,15 @@ export default {
 | 
			
		||||
 | 
			
		||||
      const [{ item_act: vote }] = await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'TIP', ${Number(sats)})`)
 | 
			
		||||
 | 
			
		||||
      const updatedItem = await models.item.findUnique({ where: { id: Number(id) } })
 | 
			
		||||
      const title = `your ${updatedItem.title ? 'post' : 'reply'} ${updatedItem.fwdUser ? 'forwarded' : 'stacked'} ${Math.floor(Number(updatedItem.msats) / 1000)} sats${updatedItem.fwdUser ? ` to @${updatedItem.fwdUser.name}` : ''}`
 | 
			
		||||
      sendUserNotification(updatedItem.userId, {
 | 
			
		||||
        title,
 | 
			
		||||
        body: updatedItem.title ? updatedItem.title : updatedItem.text,
 | 
			
		||||
        data: { url: `/items/${updatedItem.id}` },
 | 
			
		||||
        tag: `TIP-${updatedItem.id}`
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        vote,
 | 
			
		||||
        sats
 | 
			
		||||
@ -1182,6 +1206,13 @@ export const createMentions = async (item, models) => {
 | 
			
		||||
          update: data,
 | 
			
		||||
          create: data
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        sendUserNotification(user.id, {
 | 
			
		||||
          title: 'you were mentioned',
 | 
			
		||||
          body: item.text,
 | 
			
		||||
          data: { url: `/items/${item.id}` },
 | 
			
		||||
          tag: 'MENTION'
 | 
			
		||||
        })
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,9 @@
 | 
			
		||||
import { AuthenticationError } from 'apollo-server-micro'
 | 
			
		||||
import { AuthenticationError, UserInputError } from 'apollo-server-micro'
 | 
			
		||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
 | 
			
		||||
import { getItem, filterClause } from './item'
 | 
			
		||||
import { getInvoice } from './wallet'
 | 
			
		||||
import { pushSubscriptionSchema, ssValidate } from '../../lib/validate'
 | 
			
		||||
import { replyToSubscription } from '../webPush'
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  Query: {
 | 
			
		||||
@ -223,6 +225,44 @@ export default {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  Mutation: {
 | 
			
		||||
    savePushSubscription: async (parent, { endpoint, p256dh, auth, oldEndpoint }, { me, models }) => {
 | 
			
		||||
      if (!me) {
 | 
			
		||||
        throw new AuthenticationError('you must be logged in')
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await ssValidate(pushSubscriptionSchema, { endpoint, p256dh, auth })
 | 
			
		||||
 | 
			
		||||
      let dbPushSubscription
 | 
			
		||||
      if (oldEndpoint) {
 | 
			
		||||
        dbPushSubscription = await models.pushSubscription.update({
 | 
			
		||||
          data: { userId: me.id, endpoint, p256dh, auth }, where: { endpoint: oldEndpoint }
 | 
			
		||||
        })
 | 
			
		||||
      } else {
 | 
			
		||||
        dbPushSubscription = await models.pushSubscription.create({
 | 
			
		||||
          data: { userId: me.id, endpoint, p256dh, auth }
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await replyToSubscription(dbPushSubscription.id, { title: 'Stacker News notifications are now active' })
 | 
			
		||||
 | 
			
		||||
      return dbPushSubscription
 | 
			
		||||
    },
 | 
			
		||||
    deletePushSubscription: async (parent, { endpoint }, { me, models }) => {
 | 
			
		||||
      if (!me) {
 | 
			
		||||
        throw new AuthenticationError('you must be logged in')
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const subscription = await models.pushSubscription.findFirst({ where: { endpoint, userId: Number(me.id) } })
 | 
			
		||||
      if (!subscription) {
 | 
			
		||||
        throw new UserInputError('endpoint not found', {
 | 
			
		||||
          argumentName: 'endpoint'
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      await models.pushSubscription.delete({ where: { id: subscription.id } })
 | 
			
		||||
      return subscription
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  Notification: {
 | 
			
		||||
    __resolveType: async (n, args, { models }) => n.type
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
@ -5,6 +5,11 @@ export default gql`
 | 
			
		||||
    notifications(cursor: String, inc: String): Notifications
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  extend type Mutation {
 | 
			
		||||
    savePushSubscription(endpoint: String!, p256dh: String!, auth: String!, oldEndpoint: String): PushSubscription
 | 
			
		||||
    deletePushSubscription(endpoint: String!): PushSubscription
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  type Votification {
 | 
			
		||||
    earnedSats: Int!
 | 
			
		||||
    item: Item!
 | 
			
		||||
@ -69,4 +74,12 @@ export default gql`
 | 
			
		||||
    cursor: String
 | 
			
		||||
    notifications: [Notification!]!
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  type PushSubscription {
 | 
			
		||||
    id: ID!
 | 
			
		||||
    userId: ID!
 | 
			
		||||
    endpoint: String!
 | 
			
		||||
    p256dh: String!
 | 
			
		||||
    auth: String!
 | 
			
		||||
  }
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										78
									
								
								api/webPush/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								api/webPush/index.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,78 @@
 | 
			
		||||
import webPush from 'web-push'
 | 
			
		||||
import models from '../models'
 | 
			
		||||
 | 
			
		||||
webPush.setVapidDetails(
 | 
			
		||||
  process.env.VAPID_MAILTO,
 | 
			
		||||
  process.env.NEXT_PUBLIC_VAPID_PUBKEY,
 | 
			
		||||
  process.env.VAPID_PRIVKEY
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const createPayload = (notification) => {
 | 
			
		||||
  // https://web.dev/push-notifications-display-a-notification/#visual-options
 | 
			
		||||
  const { title, ...options } = notification
 | 
			
		||||
  return JSON.stringify({
 | 
			
		||||
    title,
 | 
			
		||||
    options: {
 | 
			
		||||
      timestamp: Date.now(),
 | 
			
		||||
      icon: '/android-chrome-96x96.png',
 | 
			
		||||
      ...options
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const createUserFilter = (tag) => {
 | 
			
		||||
  // filter users by notification settings
 | 
			
		||||
  const tagMap = {
 | 
			
		||||
    REPLY: 'noteAllDescendants',
 | 
			
		||||
    MENTION: 'noteMentions',
 | 
			
		||||
    TIP: 'noteItemSats'
 | 
			
		||||
  }
 | 
			
		||||
  const key = tagMap[tag.split('-')[0]]
 | 
			
		||||
  return key ? { user: { [key]: true } } : undefined
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const sendNotification = (subscription, payload) => {
 | 
			
		||||
  const { id, endpoint, p256dh, auth } = subscription
 | 
			
		||||
  return webPush.sendNotification({ endpoint, keys: { p256dh, auth } }, payload)
 | 
			
		||||
    .catch((err) => {
 | 
			
		||||
      if (err.statusCode === 400) {
 | 
			
		||||
        console.log('[webPush] invalid request: ', err)
 | 
			
		||||
      } else if (err.statusCode === 403) {
 | 
			
		||||
        console.log('[webPush] auth error: ', err)
 | 
			
		||||
      } else if (err.statusCode === 404 || err.statusCode === 410) {
 | 
			
		||||
        console.log('[webPush] subscription has expired or is no longer valid: ', err)
 | 
			
		||||
        return models.pushSubscription.delete({ where: { id } })
 | 
			
		||||
      } else if (err.statusCode === 413) {
 | 
			
		||||
        console.log('[webPush] payload too large: ', err)
 | 
			
		||||
      } else if (err.statusCode === 429) {
 | 
			
		||||
        console.log('[webPush] too many requests: ', err)
 | 
			
		||||
      } else {
 | 
			
		||||
        console.log('[webPush] error: ', err)
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function sendUserNotification (userId, notification) {
 | 
			
		||||
  try {
 | 
			
		||||
    const userFilter = createUserFilter(notification.tag)
 | 
			
		||||
    const payload = createPayload(notification)
 | 
			
		||||
    const subscriptions = await models.pushSubscription.findMany({
 | 
			
		||||
      where: { userId, ...userFilter }
 | 
			
		||||
    })
 | 
			
		||||
    await Promise.allSettled(
 | 
			
		||||
      subscriptions.map(subscription => sendNotification(subscription, payload))
 | 
			
		||||
    )
 | 
			
		||||
  } catch (err) {
 | 
			
		||||
    console.log('[webPush] error sending user notification: ', err)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function replyToSubscription (subscriptionId, notification) {
 | 
			
		||||
  try {
 | 
			
		||||
    const payload = createPayload(notification)
 | 
			
		||||
    const subscription = await models.pushSubscription.findUnique({ where: { id: subscriptionId } })
 | 
			
		||||
    await sendNotification(subscription, payload)
 | 
			
		||||
  } catch (err) {
 | 
			
		||||
    console.log('[webPush] error sending subscription reply: ', err)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -22,6 +22,9 @@ export function DiscussionForm ({
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  const client = useApolloClient()
 | 
			
		||||
  const schema = discussionSchema(client)
 | 
			
		||||
  // if Web Share Target API was used
 | 
			
		||||
  const shareTitle = router.query.title
 | 
			
		||||
 | 
			
		||||
  // const me = useMe()
 | 
			
		||||
  const [upsertDiscussion] = useMutation(
 | 
			
		||||
    gql`
 | 
			
		||||
@ -51,7 +54,7 @@ export function DiscussionForm ({
 | 
			
		||||
  return (
 | 
			
		||||
    <Form
 | 
			
		||||
      initial={{
 | 
			
		||||
        title: item?.title || '',
 | 
			
		||||
        title: item?.title || shareTitle || '',
 | 
			
		||||
        text: item?.text || '',
 | 
			
		||||
        ...AdvPostInitial({ forward: item?.fwdUser?.name }),
 | 
			
		||||
        ...SubSelectInitial({ sub: item?.subName || sub?.name })
 | 
			
		||||
 | 
			
		||||
@ -79,7 +79,6 @@ export function InputSkeleton ({ label, hint }) {
 | 
			
		||||
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, onKeyDown, innerRef, ...props }) {
 | 
			
		||||
  const [tab, setTab] = useState('write')
 | 
			
		||||
  const [, meta, helpers] = useField(props)
 | 
			
		||||
  const formik = useFormikContext()
 | 
			
		||||
  const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
 | 
			
		||||
  innerRef = innerRef || useRef(null)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,14 @@
 | 
			
		||||
 | 
			
		||||
.markdownInput textarea {
 | 
			
		||||
    margin-top: -1px;
 | 
			
		||||
    font-size: 94%;
 | 
			
		||||
    line-height: 140%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media screen and (min-width: 767px) {
 | 
			
		||||
    .markdownInput textarea {
 | 
			
		||||
        line-height: 130%;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.markdownInput .text {
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,6 @@ import CowboyHat from './cowboy-hat'
 | 
			
		||||
import { Form, Select } from './form'
 | 
			
		||||
import SearchIcon from '../svgs/search-line.svg'
 | 
			
		||||
import BackArrow from '../svgs/arrow-left-line.svg'
 | 
			
		||||
import { useNotification } from './notifications'
 | 
			
		||||
import { SUBS } from '../lib/constants'
 | 
			
		||||
import { useFireworks } from './fireworks'
 | 
			
		||||
 | 
			
		||||
@ -51,7 +50,6 @@ export default function Header ({ sub }) {
 | 
			
		||||
  const [prefix, setPrefix] = useState('')
 | 
			
		||||
  const [path, setPath] = useState('')
 | 
			
		||||
  const me = useMe()
 | 
			
		||||
  const notification = useNotification()
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    // there's always at least 2 on the split, e.g. '/' yields ['','']
 | 
			
		||||
@ -73,17 +71,7 @@ export default function Header ({ sub }) {
 | 
			
		||||
    }
 | 
			
		||||
  `, {
 | 
			
		||||
    pollInterval: 30000,
 | 
			
		||||
    fetchPolicy: 'cache-and-network',
 | 
			
		||||
    // Trigger onComplete after every poll
 | 
			
		||||
    // See https://github.com/apollographql/apollo-client/issues/5531#issuecomment-568235629
 | 
			
		||||
    notifyOnNetworkStatusChange: true,
 | 
			
		||||
    onCompleted: (data) => {
 | 
			
		||||
      const notified = JSON.parse(localStorage.getItem('notified')) || false
 | 
			
		||||
      if (!notified && data.hasNewNotes) {
 | 
			
		||||
        notification.show('you have Stacker News notifications')
 | 
			
		||||
      }
 | 
			
		||||
      localStorage.setItem('notified', data.hasNewNotes)
 | 
			
		||||
    }
 | 
			
		||||
    fetchPolicy: 'cache-and-network'
 | 
			
		||||
  })
 | 
			
		||||
  // const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime())
 | 
			
		||||
  // useEffect(() => {
 | 
			
		||||
 | 
			
		||||
@ -19,6 +19,9 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  const client = useApolloClient()
 | 
			
		||||
  const schema = linkSchema(client)
 | 
			
		||||
  // if Web Share Target API was used
 | 
			
		||||
  const shareUrl = router.query.url
 | 
			
		||||
  const shareTitle = router.query.title
 | 
			
		||||
 | 
			
		||||
  const [getPageTitleAndUnshorted, { data }] = useLazyQuery(gql`
 | 
			
		||||
    query PageTitleAndUnshorted($url: String!) {
 | 
			
		||||
@ -95,8 +98,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Form
 | 
			
		||||
      initial={{
 | 
			
		||||
        title: item?.title || '',
 | 
			
		||||
        url: item?.url || '',
 | 
			
		||||
        title: item?.title || shareTitle || '',
 | 
			
		||||
        url: item?.url || shareUrl || '',
 | 
			
		||||
        ...AdvPostInitial({ forward: item?.fwdUser?.name }),
 | 
			
		||||
        ...SubSelectInitial({ sub: item?.subName || sub?.name })
 | 
			
		||||
      }}
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
import { useState, useCallback, useContext, useEffect, createContext } from 'react'
 | 
			
		||||
import { useState, useEffect } from 'react'
 | 
			
		||||
import { useQuery } from '@apollo/client'
 | 
			
		||||
import Comment, { CommentSkeleton } from './comment'
 | 
			
		||||
import Item from './item'
 | 
			
		||||
@ -18,6 +18,8 @@ import BaldIcon from '../svgs/bald.svg'
 | 
			
		||||
import { RootProvider } from './root'
 | 
			
		||||
import { Alert } from 'react-bootstrap'
 | 
			
		||||
import styles from './notifications.module.css'
 | 
			
		||||
import { useServiceWorker } from './serviceworker'
 | 
			
		||||
import { Checkbox, Form } from './form'
 | 
			
		||||
 | 
			
		||||
function Notification ({ n }) {
 | 
			
		||||
  switch (n.__typename) {
 | 
			
		||||
@ -254,13 +256,16 @@ function Reply ({ n }) {
 | 
			
		||||
 | 
			
		||||
function NotificationAlert () {
 | 
			
		||||
  const [showAlert, setShowAlert] = useState(false)
 | 
			
		||||
  const pushNotify = useNotification()
 | 
			
		||||
  const [hasSubscription, setHasSubscription] = useState(false)
 | 
			
		||||
  const [error, setError] = useState(null)
 | 
			
		||||
  const sw = useServiceWorker()
 | 
			
		||||
 | 
			
		||||
  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 isSupported = sw.support.serviceWorker && sw.support.pushManager && sw.support.notification
 | 
			
		||||
    const isDefaultPermission = sw.permission.notification === 'default'
 | 
			
		||||
    setShowAlert(isSupported && isDefaultPermission && !localStorage.getItem('hideNotifyPrompt'))
 | 
			
		||||
    sw.registration?.pushManager.getSubscription().then(subscription => setHasSubscription(!!subscription))
 | 
			
		||||
  }, [sw])
 | 
			
		||||
 | 
			
		||||
  const close = () => {
 | 
			
		||||
    localStorage.setItem('hideNotifyPrompt', 'yep')
 | 
			
		||||
@ -268,22 +273,37 @@ function NotificationAlert () {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    showAlert
 | 
			
		||||
    error
 | 
			
		||||
      ? (
 | 
			
		||||
        <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 variant='danger' dismissible onClose={() => setError(null)}>
 | 
			
		||||
          <span>{error.toString()}</span>
 | 
			
		||||
        </Alert>
 | 
			
		||||
        )
 | 
			
		||||
      : null
 | 
			
		||||
      : 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' initial={{ pushNotify: hasSubscription }}>
 | 
			
		||||
            <Checkbox
 | 
			
		||||
              name='pushNotify' label='push notifications' inline checked={hasSubscription} handleChange={async () => {
 | 
			
		||||
                await sw.togglePushSubscription().catch(setError)
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          </Form>
 | 
			
		||||
          )
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -329,55 +349,3 @@ function CommentsFlatSkeleton () {
 | 
			
		||||
    </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)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										111
									
								
								components/serviceworker.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								components/serviceworker.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,111 @@
 | 
			
		||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
 | 
			
		||||
import { Workbox } from 'workbox-window'
 | 
			
		||||
import { gql, useMutation } from '@apollo/client'
 | 
			
		||||
 | 
			
		||||
const applicationServerKey = process.env.NEXT_PUBLIC_VAPID_PUBKEY
 | 
			
		||||
 | 
			
		||||
const ServiceWorkerContext = createContext()
 | 
			
		||||
 | 
			
		||||
export const ServiceWorkerProvider = ({ children }) => {
 | 
			
		||||
  const [registration, setRegistration] = useState(null)
 | 
			
		||||
  const [support, setSupport] = useState({ serviceWorker: undefined, pushManager: undefined })
 | 
			
		||||
  const [permission, setPermission] = useState({ notification: undefined })
 | 
			
		||||
  const [savePushSubscription] = useMutation(
 | 
			
		||||
    gql`
 | 
			
		||||
      mutation savePushSubscription(
 | 
			
		||||
        $endpoint: String!
 | 
			
		||||
        $p256dh: String!
 | 
			
		||||
        $auth: String!
 | 
			
		||||
      ) {
 | 
			
		||||
        savePushSubscription(
 | 
			
		||||
          endpoint: $endpoint
 | 
			
		||||
          p256dh: $p256dh
 | 
			
		||||
          auth: $auth
 | 
			
		||||
        ) {
 | 
			
		||||
          id
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    `)
 | 
			
		||||
  const [deletePushSubscription] = useMutation(
 | 
			
		||||
    gql`
 | 
			
		||||
        mutation deletePushSubscription($endpoint: String!) {
 | 
			
		||||
          deletePushSubscription(endpoint: $endpoint) {
 | 
			
		||||
            id
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      `)
 | 
			
		||||
 | 
			
		||||
  // I am not entirely sure if this is needed since at least in Brave,
 | 
			
		||||
  // using `registration.pushManager.subscribe` also prompts the user.
 | 
			
		||||
  // However, I am keeping this here since that's how it's done in most guides.
 | 
			
		||||
  // Could be that this is required for the `registration.showNotification` call
 | 
			
		||||
  // to work or that some browsers will break without this.
 | 
			
		||||
  const requestNotificationPermission = useCallback(() => {
 | 
			
		||||
    // https://web.dev/push-notifications-subscribing-a-user/#requesting-permission
 | 
			
		||||
    return new Promise(function (resolve, reject) {
 | 
			
		||||
      const permission = Notification.requestPermission(function (result) {
 | 
			
		||||
        resolve(result)
 | 
			
		||||
      })
 | 
			
		||||
      if (permission) {
 | 
			
		||||
        permission.then(resolve, reject)
 | 
			
		||||
      }
 | 
			
		||||
    }).then(function (permission) {
 | 
			
		||||
      setPermission({ notification: permission })
 | 
			
		||||
      if (permission === 'granted') return subscribeToPushNotifications()
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const subscribeToPushNotifications = async () => {
 | 
			
		||||
    const subscribeOptions = { userVisibleOnly: true, applicationServerKey }
 | 
			
		||||
    // Brave users must enable a flag in brave://settings/privacy first
 | 
			
		||||
    // see https://stackoverflow.com/a/69624651
 | 
			
		||||
    let pushSubscription = await registration.pushManager.subscribe(subscribeOptions)
 | 
			
		||||
    // convert keys from ArrayBuffer to string
 | 
			
		||||
    pushSubscription = JSON.parse(JSON.stringify(pushSubscription))
 | 
			
		||||
    const variables = {
 | 
			
		||||
      endpoint: pushSubscription.endpoint,
 | 
			
		||||
      p256dh: pushSubscription.keys.p256dh,
 | 
			
		||||
      auth: pushSubscription.keys.auth
 | 
			
		||||
    }
 | 
			
		||||
    await savePushSubscription({ variables })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const unsubscribeFromPushNotifications = async (subscription) => {
 | 
			
		||||
    await subscription.unsubscribe()
 | 
			
		||||
    const { endpoint } = subscription
 | 
			
		||||
    await deletePushSubscription({ variables: { endpoint } })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const togglePushSubscription = useCallback(async () => {
 | 
			
		||||
    const pushSubscription = await registration.pushManager.getSubscription()
 | 
			
		||||
    if (pushSubscription) return unsubscribeFromPushNotifications(pushSubscription)
 | 
			
		||||
    return subscribeToPushNotifications()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setSupport({
 | 
			
		||||
      serviceWorker: 'serviceWorker' in navigator,
 | 
			
		||||
      notification: 'Notification' in window,
 | 
			
		||||
      pushManager: 'PushManager' in window
 | 
			
		||||
    })
 | 
			
		||||
    setPermission({ notification: 'Notification' in window ? Notification.permission : 'denied' })
 | 
			
		||||
  }, [])
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!support.serviceWorker) return
 | 
			
		||||
    const wb = new Workbox('/sw.js', { scope: '/' })
 | 
			
		||||
    wb.register().then(registration => {
 | 
			
		||||
      setRegistration(registration)
 | 
			
		||||
    })
 | 
			
		||||
  }, [support.serviceWorker])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <ServiceWorkerContext.Provider value={{ registration, support, permission, requestNotificationPermission, togglePushSubscription }}>
 | 
			
		||||
      {children}
 | 
			
		||||
    </ServiceWorkerContext.Provider>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useServiceWorker () {
 | 
			
		||||
  return useContext(ServiceWorkerContext)
 | 
			
		||||
}
 | 
			
		||||
@ -230,3 +230,9 @@ export const inviteSchema = Yup.object({
 | 
			
		||||
  gift: intValidator.positive('must be greater than 0').required('required'),
 | 
			
		||||
  limit: intValidator.positive('must be positive')
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export const pushSubscriptionSchema = Yup.object({
 | 
			
		||||
  endpoint: Yup.string().url().required('required').trim(),
 | 
			
		||||
  p256dh: Yup.string().required('required').trim(),
 | 
			
		||||
  auth: Yup.string().required('required').trim()
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										246
									
								
								next.config.js
									
									
									
									
									
								
							
							
						
						
									
										246
									
								
								next.config.js
									
									
									
									
									
								
							@ -1,6 +1,6 @@
 | 
			
		||||
const { withPlausibleProxy } = require('next-plausible')
 | 
			
		||||
const withPWA = require('next-pwa')
 | 
			
		||||
const defaultRuntimeCaching = require('next-pwa/cache')
 | 
			
		||||
const { InjectManifest } = require('workbox-webpack-plugin')
 | 
			
		||||
const { generatePrecacheManifest } = require('./sw/build')
 | 
			
		||||
 | 
			
		||||
const isProd = process.env.NODE_ENV === 'production'
 | 
			
		||||
const corsHeaders = [
 | 
			
		||||
@ -19,128 +19,124 @@ const commitHash = isProd
 | 
			
		||||
  ? Object.keys(require('/opt/elasticbeanstalk/deployment/app_version_manifest.json').RuntimeSources['stacker.news'])[0].match(/^app-(.+)-/)[1] // eslint-disable-line
 | 
			
		||||
  : require('child_process').execSync('git rev-parse HEAD').toString().slice(0, 4)
 | 
			
		||||
 | 
			
		||||
module.exports = withPWA({
 | 
			
		||||
  dest: 'public',
 | 
			
		||||
  register: true,
 | 
			
		||||
  customWorkerDir: 'sw',
 | 
			
		||||
  runtimeCaching: [
 | 
			
		||||
    {
 | 
			
		||||
      urlPattern: /\/_next\/data\/.+\/.+\.json$/i,
 | 
			
		||||
      handler: 'NetworkFirst',
 | 
			
		||||
      options: {
 | 
			
		||||
        cacheName: 'next-data',
 | 
			
		||||
        expiration: {
 | 
			
		||||
          maxEntries: 32,
 | 
			
		||||
          maxAgeSeconds: 24 * 60 * 60 // 24 hours
 | 
			
		||||
        }
 | 
			
		||||
module.exports = withPlausibleProxy()({
 | 
			
		||||
  env: {
 | 
			
		||||
    NEXT_PUBLIC_COMMIT_HASH: commitHash
 | 
			
		||||
  },
 | 
			
		||||
  compress: false,
 | 
			
		||||
  experimental: {
 | 
			
		||||
    scrollRestoration: true
 | 
			
		||||
  },
 | 
			
		||||
  generateBuildId: isProd ? async () => commitHash : undefined,
 | 
			
		||||
  // Use the CDN in production and localhost for development.
 | 
			
		||||
  assetPrefix: isProd ? 'https://a.stacker.news' : undefined,
 | 
			
		||||
  async headers () {
 | 
			
		||||
    return [
 | 
			
		||||
      {
 | 
			
		||||
        source: '/_next/:asset*',
 | 
			
		||||
        headers: corsHeaders
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        source: '/Lightningvolt-xoqm.ttf',
 | 
			
		||||
        headers: [
 | 
			
		||||
          ...corsHeaders,
 | 
			
		||||
          {
 | 
			
		||||
            key: 'Cache-Control',
 | 
			
		||||
            value: 'public, max-age=31536000, immutable'
 | 
			
		||||
          }
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        source: '/.well-known/:slug*',
 | 
			
		||||
        headers: [
 | 
			
		||||
          ...corsHeaders
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        source: '/api/lnauth',
 | 
			
		||||
        headers: [
 | 
			
		||||
          ...corsHeaders
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        source: '/api/lnurlp/:slug*',
 | 
			
		||||
        headers: [
 | 
			
		||||
          ...corsHeaders
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    ...defaultRuntimeCaching.filter((c) => c.options.cacheName !== 'next-data')
 | 
			
		||||
  ]
 | 
			
		||||
})(
 | 
			
		||||
  withPlausibleProxy()({
 | 
			
		||||
    env: {
 | 
			
		||||
      NEXT_PUBLIC_COMMIT_HASH: commitHash
 | 
			
		||||
    },
 | 
			
		||||
    compress: false,
 | 
			
		||||
    experimental: {
 | 
			
		||||
      scrollRestoration: true
 | 
			
		||||
    },
 | 
			
		||||
    generateBuildId: isProd ? async () => commitHash : undefined,
 | 
			
		||||
    // Use the CDN in production and localhost for development.
 | 
			
		||||
    assetPrefix: isProd ? 'https://a.stacker.news' : undefined,
 | 
			
		||||
    async headers () {
 | 
			
		||||
      return [
 | 
			
		||||
        {
 | 
			
		||||
          source: '/_next/:asset*',
 | 
			
		||||
          headers: corsHeaders
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          source: '/Lightningvolt-xoqm.ttf',
 | 
			
		||||
          headers: [
 | 
			
		||||
            ...corsHeaders,
 | 
			
		||||
            {
 | 
			
		||||
              key: 'Cache-Control',
 | 
			
		||||
              value: 'public, max-age=31536000, immutable'
 | 
			
		||||
            }
 | 
			
		||||
          ]
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          source: '/.well-known/:slug*',
 | 
			
		||||
          headers: [
 | 
			
		||||
            ...corsHeaders
 | 
			
		||||
          ]
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          source: '/api/lnauth',
 | 
			
		||||
          headers: [
 | 
			
		||||
            ...corsHeaders
 | 
			
		||||
          ]
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          source: '/api/lnurlp/:slug*',
 | 
			
		||||
          headers: [
 | 
			
		||||
            ...corsHeaders
 | 
			
		||||
          ]
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    },
 | 
			
		||||
    async rewrites () {
 | 
			
		||||
      return [
 | 
			
		||||
        {
 | 
			
		||||
          source: '/faq',
 | 
			
		||||
          destination: '/items/349'
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          source: '/story',
 | 
			
		||||
          destination: '/items/1620'
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          source: '/privacy',
 | 
			
		||||
          destination: '/items/76894'
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          source: '/changes',
 | 
			
		||||
          destination: '/items/78763'
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          source: '/guide',
 | 
			
		||||
          destination: '/items/81862'
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          source: '/daily',
 | 
			
		||||
          destination: '/api/daily'
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          source: '/.well-known/lnurlp/:username',
 | 
			
		||||
          destination: '/api/lnurlp/:username'
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          source: '/.well-known/nostr.json',
 | 
			
		||||
          destination: '/api/nostr/nip05'
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          source: '/.well-known/web-app-origin-association',
 | 
			
		||||
          destination: '/api/web-app-origin-association'
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          source: '/~:sub',
 | 
			
		||||
          destination: '/~/:sub'
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          source: '/~:sub/:slug*',
 | 
			
		||||
          destination: '/~/:sub/:slug*'
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    },
 | 
			
		||||
    async redirects () {
 | 
			
		||||
      return [
 | 
			
		||||
        {
 | 
			
		||||
          source: '/statistics',
 | 
			
		||||
          destination: '/satistics?inc=invoice,withdrawal',
 | 
			
		||||
          permanent: true
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  async rewrites () {
 | 
			
		||||
    return [
 | 
			
		||||
      {
 | 
			
		||||
        source: '/faq',
 | 
			
		||||
        destination: '/items/349'
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        source: '/story',
 | 
			
		||||
        destination: '/items/1620'
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        source: '/privacy',
 | 
			
		||||
        destination: '/items/76894'
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        source: '/changes',
 | 
			
		||||
        destination: '/items/78763'
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        source: '/guide',
 | 
			
		||||
        destination: '/items/81862'
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        source: '/daily',
 | 
			
		||||
        destination: '/api/daily'
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        source: '/.well-known/lnurlp/:username',
 | 
			
		||||
        destination: '/api/lnurlp/:username'
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        source: '/.well-known/nostr.json',
 | 
			
		||||
        destination: '/api/nostr/nip05'
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        source: '/.well-known/web-app-origin-association',
 | 
			
		||||
        destination: '/api/web-app-origin-association'
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        source: '/~:sub',
 | 
			
		||||
        destination: '/~/:sub'
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        source: '/~:sub/:slug*',
 | 
			
		||||
        destination: '/~/:sub/:slug*'
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  async redirects () {
 | 
			
		||||
    return [
 | 
			
		||||
      {
 | 
			
		||||
        source: '/statistics',
 | 
			
		||||
        destination: '/satistics?inc=invoice,withdrawal',
 | 
			
		||||
        permanent: true
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  webpack: (config, { isServer }) => {
 | 
			
		||||
    if (isServer) {
 | 
			
		||||
      generatePrecacheManifest()
 | 
			
		||||
      config.plugins.push(
 | 
			
		||||
        new InjectManifest({
 | 
			
		||||
          // ignore the precached manifest which includes the webpack assets
 | 
			
		||||
          // since they are not useful to us
 | 
			
		||||
          exclude: [/.*/],
 | 
			
		||||
          // by default, webpack saves service worker at .next/server/
 | 
			
		||||
          swDest: '../../public/sw.js',
 | 
			
		||||
          swSrc: './sw/index.js'
 | 
			
		||||
        })
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
)
 | 
			
		||||
    return config
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										9441
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										9441
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -43,7 +43,6 @@
 | 
			
		||||
    "next": "^12.3.2",
 | 
			
		||||
    "next-auth": "^3.29.10",
 | 
			
		||||
    "next-plausible": "^3.6.4",
 | 
			
		||||
    "next-pwa": "^5.6.0",
 | 
			
		||||
    "next-seo": "^4.29.0",
 | 
			
		||||
    "nextjs-progressbar": "0.0.16",
 | 
			
		||||
    "node-s3-url-encode": "^0.0.4",
 | 
			
		||||
@ -82,7 +81,14 @@
 | 
			
		||||
    "url-unshort": "^6.1.0",
 | 
			
		||||
    "use-dark-mode": "^2.3.1",
 | 
			
		||||
    "uuid": "^8.3.2",
 | 
			
		||||
    "web-push": "^3.6.2",
 | 
			
		||||
    "webln": "^0.2.2",
 | 
			
		||||
    "workbox-precaching": "^7.0.0",
 | 
			
		||||
    "workbox-recipes": "^7.0.0",
 | 
			
		||||
    "workbox-routing": "^7.0.0",
 | 
			
		||||
    "workbox-strategies": "^7.0.0",
 | 
			
		||||
    "workbox-webpack-plugin": "^7.0.0",
 | 
			
		||||
    "workbox-window": "^7.0.0",
 | 
			
		||||
    "yup": "^0.32.11"
 | 
			
		||||
  },
 | 
			
		||||
  "engines": {
 | 
			
		||||
 | 
			
		||||
@ -13,8 +13,8 @@ import Moon from '../svgs/moon-fill.svg'
 | 
			
		||||
import Layout from '../components/layout'
 | 
			
		||||
import { ShowModalProvider } from '../components/modal'
 | 
			
		||||
import ErrorBoundary from '../components/error-boundary'
 | 
			
		||||
import { NotificationProvider } from '../components/notifications'
 | 
			
		||||
import { FireworksProvider } from '../components/fireworks'
 | 
			
		||||
import { ServiceWorkerProvider } from '../components/serviceworker'
 | 
			
		||||
 | 
			
		||||
function CSRWrapper ({ Component, apollo, ...props }) {
 | 
			
		||||
  const { data, error } = useQuery(gql`${apollo.query}`, { variables: apollo.variables, fetchPolicy: 'cache-first' })
 | 
			
		||||
@ -89,7 +89,7 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
 | 
			
		||||
          <Provider session={session}>
 | 
			
		||||
            <ApolloProvider client={client}>
 | 
			
		||||
              <MeProvider me={me}>
 | 
			
		||||
                <NotificationProvider>
 | 
			
		||||
                <ServiceWorkerProvider>
 | 
			
		||||
                  <PriceProvider price={price}>
 | 
			
		||||
                    <FireworksProvider>
 | 
			
		||||
                      <ShowModalProvider>
 | 
			
		||||
@ -99,7 +99,7 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
 | 
			
		||||
                      </ShowModalProvider>
 | 
			
		||||
                    </FireworksProvider>
 | 
			
		||||
                  </PriceProvider>
 | 
			
		||||
                </NotificationProvider>
 | 
			
		||||
                </ServiceWorkerProvider>
 | 
			
		||||
              </MeProvider>
 | 
			
		||||
            </ApolloProvider>
 | 
			
		||||
          </Provider>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										24
									
								
								pages/share.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								pages/share.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
export function getServerSideProps ({ query }) {
 | 
			
		||||
  // used to redirect to appropriate post type if Web Share Target API was used
 | 
			
		||||
  const title = query.title
 | 
			
		||||
  const text = query.text
 | 
			
		||||
  let url = query.url
 | 
			
		||||
  // apps may share links as text
 | 
			
		||||
  if (text && /^https?:\/\//.test(text)) url = text
 | 
			
		||||
 | 
			
		||||
  let destination = '/post'
 | 
			
		||||
  if (url && title) {
 | 
			
		||||
    destination += `?type=link&url=${url}&title=${title}`
 | 
			
		||||
  } else if (title) {
 | 
			
		||||
    destination += `?type=discussion&title=${title}`
 | 
			
		||||
    if (text) destination += `&text=${text}`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    redirect: {
 | 
			
		||||
      destination
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default () => null
 | 
			
		||||
@ -0,0 +1,17 @@
 | 
			
		||||
-- CreateTable
 | 
			
		||||
CREATE TABLE "PushSubscription" (
 | 
			
		||||
    "id" SERIAL NOT NULL,
 | 
			
		||||
    "userId" INTEGER NOT NULL,
 | 
			
		||||
    "endpoint" TEXT NOT NULL,
 | 
			
		||||
    "p256dh" TEXT NOT NULL,
 | 
			
		||||
    "auth" TEXT NOT NULL,
 | 
			
		||||
    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
 | 
			
		||||
 | 
			
		||||
    PRIMARY KEY ("id")
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
-- CreateIndex
 | 
			
		||||
CREATE INDEX "PushSubscription.userId_index" ON "PushSubscription"("userId");
 | 
			
		||||
 | 
			
		||||
-- AddForeignKey
 | 
			
		||||
ALTER TABLE "PushSubscription" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
 | 
			
		||||
@ -99,6 +99,7 @@ model User {
 | 
			
		||||
  Bookmarks           Bookmark[]
 | 
			
		||||
  Subscriptions       Subscription[]
 | 
			
		||||
  ThreadSubscriptions ThreadSubscription[]
 | 
			
		||||
  PushSubscriptions   PushSubscription[]
 | 
			
		||||
 | 
			
		||||
  @@index([createdAt])
 | 
			
		||||
  @@index([inviteId])
 | 
			
		||||
@ -579,3 +580,15 @@ model ThreadSubscription {
 | 
			
		||||
  @@id([userId, itemId])
 | 
			
		||||
  @@index([createdAt])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model PushSubscription {
 | 
			
		||||
  id        Int       @id @default(autoincrement())
 | 
			
		||||
  user      User      @relation(fields: [userId], references: [id])
 | 
			
		||||
  userId    Int
 | 
			
		||||
  endpoint  String
 | 
			
		||||
  p256dh    String
 | 
			
		||||
  auth      String
 | 
			
		||||
  createdAt DateTime  @default(now()) @map(name: "created_at")
 | 
			
		||||
 | 
			
		||||
  @@index([userId])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								public/android-chrome-96x96.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/android-chrome-96x96.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 7.8 KiB  | 
@ -12,6 +12,11 @@
 | 
			
		||||
      "type": "image/png",
 | 
			
		||||
      "sizes": "512x512"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "src": "/android-chrome-96x96.png",
 | 
			
		||||
      "type": "image/png",
 | 
			
		||||
      "sizes": "96x96"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "src": "/android-chrome-24x24.png",
 | 
			
		||||
      "type": "image/png",
 | 
			
		||||
@ -26,5 +31,14 @@
 | 
			
		||||
    {
 | 
			
		||||
      "origin": "https://stacker.news"
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
  ],
 | 
			
		||||
  "share_target": {
 | 
			
		||||
    "action": "/share",
 | 
			
		||||
    "method": "GET",
 | 
			
		||||
    "params": {
 | 
			
		||||
      "title": "title",
 | 
			
		||||
      "text": "text",
 | 
			
		||||
      "url": "url"
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										65
									
								
								sw/build.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								sw/build.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,65 @@
 | 
			
		||||
const crypto = require('crypto')
 | 
			
		||||
const fs = require('fs')
 | 
			
		||||
const path = require('path')
 | 
			
		||||
 | 
			
		||||
const getRevision = filePath => crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex')
 | 
			
		||||
 | 
			
		||||
function formatBytes (bytes, decimals = 2) {
 | 
			
		||||
  if (bytes === 0) {
 | 
			
		||||
    return '0 B'
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const k = 1024
 | 
			
		||||
  const sizes = ['B', 'KB', 'MB']
 | 
			
		||||
 | 
			
		||||
  const i = Math.floor(Math.log(bytes) / Math.log(k))
 | 
			
		||||
  const formattedSize = parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))
 | 
			
		||||
 | 
			
		||||
  return `${formattedSize} ${sizes[i]}`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function generatePrecacheManifest () {
 | 
			
		||||
  const manifest = []
 | 
			
		||||
  let size = 0
 | 
			
		||||
 | 
			
		||||
  const addToManifest = (filePath, url, s) => {
 | 
			
		||||
    const revision = getRevision(filePath)
 | 
			
		||||
    manifest.push({ url, revision })
 | 
			
		||||
    size += s
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const staticDir = path.join(__dirname, '../public')
 | 
			
		||||
  const staticFiles = fs.readdirSync(staticDir)
 | 
			
		||||
  const staticMatch = f => [/\.(gif|jpe?g|ico|png|ttf|webmanifest)$/, /^darkmode\.js$/].some(m => m.test(f))
 | 
			
		||||
  staticFiles.filter(staticMatch).forEach(file => {
 | 
			
		||||
    const filePath = path.join(staticDir, file)
 | 
			
		||||
    const stats = fs.statSync(filePath)
 | 
			
		||||
    if (stats.isFile()) {
 | 
			
		||||
      addToManifest(filePath, '/' + file, stats.size)
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const pagesDir = path.join(__dirname, '../pages')
 | 
			
		||||
  const precacheURLs = ['/offline']
 | 
			
		||||
  const pagesFiles = fs.readdirSync(pagesDir)
 | 
			
		||||
  const fileToUrl = f => '/' + f.replace(/\.js$/, '')
 | 
			
		||||
  const pageMatch = f => precacheURLs.some(url => fileToUrl(f) === url)
 | 
			
		||||
  pagesFiles.filter(pageMatch).forEach(file => {
 | 
			
		||||
    const filePath = path.join(pagesDir, file)
 | 
			
		||||
    const stats = fs.statSync(filePath)
 | 
			
		||||
    if (stats.isFile()) {
 | 
			
		||||
      // This is not ideal since dependencies of the pages may have changed
 | 
			
		||||
      // but we would still generate the same revision ...
 | 
			
		||||
      // The ideal solution would be to create a revision from the file generated by webpack
 | 
			
		||||
      // in .next/server/pages but the file may not exist yet when we run this script
 | 
			
		||||
      addToManifest(filePath, fileToUrl(file), stats.size)
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const output = 'sw/precache-manifest.json'
 | 
			
		||||
  fs.writeFileSync(output, JSON.stringify(manifest, null, 2))
 | 
			
		||||
 | 
			
		||||
  console.log(`Created precache manifest at ${output}. Cache will include ${manifest.length} URLs with a size of ${formatBytes(size)}.`)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = { generatePrecacheManifest }
 | 
			
		||||
							
								
								
									
										98
									
								
								sw/index.js
									
									
									
									
									
								
							
							
						
						
									
										98
									
								
								sw/index.js
									
									
									
									
									
								
							@ -1,2 +1,96 @@
 | 
			
		||||
// Uncomment to disable workbox logging during development
 | 
			
		||||
// self.__WB_DISABLE_DEV_LOGS = true
 | 
			
		||||
import { precacheAndRoute } from 'workbox-precaching'
 | 
			
		||||
import { offlineFallback } from 'workbox-recipes'
 | 
			
		||||
import { setDefaultHandler } from 'workbox-routing'
 | 
			
		||||
import { NetworkOnly } from 'workbox-strategies'
 | 
			
		||||
import manifest from './precache-manifest.json'
 | 
			
		||||
 | 
			
		||||
// ignore precache manifest generated by InjectManifest
 | 
			
		||||
// self.__WB_MANIFEST
 | 
			
		||||
 | 
			
		||||
precacheAndRoute(manifest)
 | 
			
		||||
 | 
			
		||||
self.addEventListener('install', () => {
 | 
			
		||||
  self.skipWaiting()
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
// Using network-only as the default strategy ensures that we fallback
 | 
			
		||||
// to the browser as if the service worker wouldn't exist.
 | 
			
		||||
// The browser may use own caching (HTTP cache).
 | 
			
		||||
// Also, the offline fallback only works if request matched a route
 | 
			
		||||
setDefaultHandler(new NetworkOnly())
 | 
			
		||||
 | 
			
		||||
// This won't work in dev because pages are never cached.
 | 
			
		||||
// See https://github.com/vercel/next.js/blob/337fb6a9aadb61c916f0121c899e463819cd3f33/server/render.js#L181-L185
 | 
			
		||||
offlineFallback({ pageFallback: '/offline' })
 | 
			
		||||
 | 
			
		||||
self.addEventListener('push', async function (event) {
 | 
			
		||||
  const payload = event.data?.json()
 | 
			
		||||
  if (!payload) return
 | 
			
		||||
  const { tag } = payload.options
 | 
			
		||||
  event.waitUntil((async () => {
 | 
			
		||||
    if (!['REPLY', 'MENTION'].includes(tag)) {
 | 
			
		||||
      return self.registration.showNotification(payload.title, payload.options)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const notifications = await self.registration.getNotifications({ tag })
 | 
			
		||||
    // since we used a tag filter, there should only be zero or one notification
 | 
			
		||||
    if (notifications.length > 1) {
 | 
			
		||||
      console.error(`more than one notification with tag ${tag} found`)
 | 
			
		||||
      return null
 | 
			
		||||
    }
 | 
			
		||||
    if (notifications.length === 0) {
 | 
			
		||||
      return self.registration.showNotification(payload.title, payload.options)
 | 
			
		||||
    }
 | 
			
		||||
    const currentNotification = notifications[0]
 | 
			
		||||
    const amount = currentNotification.data?.amount ? currentNotification.data.amount + 1 : 2
 | 
			
		||||
    let title = ''
 | 
			
		||||
    if (tag === 'REPLY') {
 | 
			
		||||
      title = `You have ${amount} new replies`
 | 
			
		||||
    } else if (tag === 'MENTION') {
 | 
			
		||||
      title = `You were mentioned ${amount} times`
 | 
			
		||||
    }
 | 
			
		||||
    currentNotification.close()
 | 
			
		||||
    const { icon } = currentNotification
 | 
			
		||||
    return self.registration.showNotification(title, { icon, tag, data: { url: '/notifications', amount } })
 | 
			
		||||
  })())
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
self.addEventListener('notificationclick', (event) => {
 | 
			
		||||
  const url = event.notification.data?.url
 | 
			
		||||
  if (url) {
 | 
			
		||||
    event.waitUntil(self.clients.openWindow(url))
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
self.addEventListener('pushsubscriptionchange', (event) => {
 | 
			
		||||
  // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event
 | 
			
		||||
  const query = `
 | 
			
		||||
  mutation savePushSubscription($endpoint: String!, $p256dh: String!, $auth: String!, $oldEndpoint: String!) {
 | 
			
		||||
    savePushSubscription(endpoint: $endpoint, p256dh: $p256dh, auth: $auth, oldEndpoint: $oldEndpoint) {
 | 
			
		||||
      id
 | 
			
		||||
    }
 | 
			
		||||
  }`
 | 
			
		||||
  const subscription = self.registration.pushManager
 | 
			
		||||
    .subscribe(event.oldSubscription.options)
 | 
			
		||||
    .then((subscription) => {
 | 
			
		||||
      // convert keys from ArrayBuffer to string
 | 
			
		||||
      subscription = JSON.parse(JSON.stringify(subscription))
 | 
			
		||||
      const variables = {
 | 
			
		||||
        endpoint: subscription.endpoint,
 | 
			
		||||
        p256dh: subscription.keys.p256dh,
 | 
			
		||||
        auth: subscription.keys.auth,
 | 
			
		||||
        oldEndpoint: event.oldSubscription.endpoint
 | 
			
		||||
      }
 | 
			
		||||
      const body = JSON.stringify({ query, variables })
 | 
			
		||||
      return fetch('/api/graphql', {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Content-type': 'application/json'
 | 
			
		||||
        },
 | 
			
		||||
        body
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
  event.waitUntil(subscription)
 | 
			
		||||
},
 | 
			
		||||
false
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user