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
|
||||||
SLASHTAGS_SECRET=
|
SLASHTAGS_SECRET=
|
||||||
|
|
||||||
|
# VAPID for Web Push
|
||||||
|
VAPID_MAILTO=
|
||||||
|
NEXT_PUBLIC_VAPID_PUBKEY=
|
||||||
|
VAPID_PRIVKEY=
|
||||||
|
|
||||||
#######################################################
|
#######################################################
|
||||||
# LND / OPTIONAL #
|
# LND / OPTIONAL #
|
||||||
# if you want to work with payments you'll need these #
|
# 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/*.cfg.yml
|
||||||
!.elasticbeanstalk/*.global.yml
|
!.elasticbeanstalk/*.global.yml
|
||||||
|
|
||||||
# auto-generated files by next-pwa / workbox
|
# service worker
|
||||||
public/sw.js
|
public/sw.js*
|
||||||
public/sw.js.map
|
sw/precache-manifest.json
|
||||||
public/workbox-*.js
|
public/workbox-*.js*
|
||||||
public/workbox-*.js.map
|
public/*-development.js
|
||||||
public/worker-*.js
|
|
||||||
public/fallback-*.js
|
|
||||||
|
@ -12,6 +12,7 @@ import { msatsToSats } from '../../lib/format'
|
|||||||
import { parse } from 'tldts'
|
import { parse } from 'tldts'
|
||||||
import uu from 'url-unshort'
|
import uu from 'url-unshort'
|
||||||
import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
|
import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
|
||||||
|
import { sendUserNotification } from '../webPush'
|
||||||
|
|
||||||
async function comments (me, models, id, sort) {
|
async function comments (me, models, id, sort) {
|
||||||
let orderBy
|
let orderBy
|
||||||
@ -893,7 +894,21 @@ export default {
|
|||||||
},
|
},
|
||||||
createComment: async (parent, data, { me, models }) => {
|
createComment: async (parent, data, { me, models }) => {
|
||||||
await ssValidate(commentSchema, data)
|
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 }) => {
|
updateComment: async (parent, { id, ...data }, { me, models }) => {
|
||||||
await ssValidate(commentSchema, data)
|
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 [{ 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 {
|
return {
|
||||||
vote,
|
vote,
|
||||||
sats
|
sats
|
||||||
@ -1182,6 +1206,13 @@ export const createMentions = async (item, models) => {
|
|||||||
update: data,
|
update: data,
|
||||||
create: data
|
create: data
|
||||||
})
|
})
|
||||||
|
|
||||||
|
sendUserNotification(user.id, {
|
||||||
|
title: 'you were mentioned',
|
||||||
|
body: item.text,
|
||||||
|
data: { url: `/items/${item.id}` },
|
||||||
|
tag: 'MENTION'
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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 { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||||
import { getItem, filterClause } from './item'
|
import { getItem, filterClause } from './item'
|
||||||
import { getInvoice } from './wallet'
|
import { getInvoice } from './wallet'
|
||||||
|
import { pushSubscriptionSchema, ssValidate } from '../../lib/validate'
|
||||||
|
import { replyToSubscription } from '../webPush'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Query: {
|
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: {
|
Notification: {
|
||||||
__resolveType: async (n, args, { models }) => n.type
|
__resolveType: async (n, args, { models }) => n.type
|
||||||
},
|
},
|
||||||
|
@ -5,6 +5,11 @@ export default gql`
|
|||||||
notifications(cursor: String, inc: String): Notifications
|
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 {
|
type Votification {
|
||||||
earnedSats: Int!
|
earnedSats: Int!
|
||||||
item: Item!
|
item: Item!
|
||||||
@ -69,4 +74,12 @@ export default gql`
|
|||||||
cursor: String
|
cursor: String
|
||||||
notifications: [Notification!]!
|
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 router = useRouter()
|
||||||
const client = useApolloClient()
|
const client = useApolloClient()
|
||||||
const schema = discussionSchema(client)
|
const schema = discussionSchema(client)
|
||||||
|
// if Web Share Target API was used
|
||||||
|
const shareTitle = router.query.title
|
||||||
|
|
||||||
// const me = useMe()
|
// const me = useMe()
|
||||||
const [upsertDiscussion] = useMutation(
|
const [upsertDiscussion] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
@ -51,7 +54,7 @@ export function DiscussionForm ({
|
|||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
title: item?.title || '',
|
title: item?.title || shareTitle || '',
|
||||||
text: item?.text || '',
|
text: item?.text || '',
|
||||||
...AdvPostInitial({ forward: item?.fwdUser?.name }),
|
...AdvPostInitial({ forward: item?.fwdUser?.name }),
|
||||||
...SubSelectInitial({ sub: item?.subName || sub?.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 }) {
|
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, onKeyDown, innerRef, ...props }) {
|
||||||
const [tab, setTab] = useState('write')
|
const [tab, setTab] = useState('write')
|
||||||
const [, meta, helpers] = useField(props)
|
const [, meta, helpers] = useField(props)
|
||||||
const formik = useFormikContext()
|
|
||||||
const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
|
const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
|
||||||
innerRef = innerRef || useRef(null)
|
innerRef = innerRef || useRef(null)
|
||||||
|
|
||||||
|
@ -4,6 +4,14 @@
|
|||||||
|
|
||||||
.markdownInput textarea {
|
.markdownInput textarea {
|
||||||
margin-top: -1px;
|
margin-top: -1px;
|
||||||
|
font-size: 94%;
|
||||||
|
line-height: 140%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 767px) {
|
||||||
|
.markdownInput textarea {
|
||||||
|
line-height: 130%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdownInput .text {
|
.markdownInput .text {
|
||||||
|
@ -18,7 +18,6 @@ import CowboyHat from './cowboy-hat'
|
|||||||
import { Form, Select } from './form'
|
import { Form, Select } from './form'
|
||||||
import SearchIcon from '../svgs/search-line.svg'
|
import SearchIcon from '../svgs/search-line.svg'
|
||||||
import BackArrow from '../svgs/arrow-left-line.svg'
|
import BackArrow from '../svgs/arrow-left-line.svg'
|
||||||
import { useNotification } from './notifications'
|
|
||||||
import { SUBS } from '../lib/constants'
|
import { SUBS } from '../lib/constants'
|
||||||
import { useFireworks } from './fireworks'
|
import { useFireworks } from './fireworks'
|
||||||
|
|
||||||
@ -51,7 +50,6 @@ export default function Header ({ sub }) {
|
|||||||
const [prefix, setPrefix] = useState('')
|
const [prefix, setPrefix] = useState('')
|
||||||
const [path, setPath] = useState('')
|
const [path, setPath] = useState('')
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const notification = useNotification()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// there's always at least 2 on the split, e.g. '/' yields ['','']
|
// there's always at least 2 on the split, e.g. '/' yields ['','']
|
||||||
@ -73,17 +71,7 @@ export default function Header ({ sub }) {
|
|||||||
}
|
}
|
||||||
`, {
|
`, {
|
||||||
pollInterval: 30000,
|
pollInterval: 30000,
|
||||||
fetchPolicy: 'cache-and-network',
|
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)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
// const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime())
|
// const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime())
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
|
@ -19,6 +19,9 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const client = useApolloClient()
|
const client = useApolloClient()
|
||||||
const schema = linkSchema(client)
|
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`
|
const [getPageTitleAndUnshorted, { data }] = useLazyQuery(gql`
|
||||||
query PageTitleAndUnshorted($url: String!) {
|
query PageTitleAndUnshorted($url: String!) {
|
||||||
@ -95,8 +98,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
title: item?.title || '',
|
title: item?.title || shareTitle || '',
|
||||||
url: item?.url || '',
|
url: item?.url || shareUrl || '',
|
||||||
...AdvPostInitial({ forward: item?.fwdUser?.name }),
|
...AdvPostInitial({ forward: item?.fwdUser?.name }),
|
||||||
...SubSelectInitial({ sub: item?.subName || sub?.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 { useQuery } from '@apollo/client'
|
||||||
import Comment, { CommentSkeleton } from './comment'
|
import Comment, { CommentSkeleton } from './comment'
|
||||||
import Item from './item'
|
import Item from './item'
|
||||||
@ -18,6 +18,8 @@ import BaldIcon from '../svgs/bald.svg'
|
|||||||
import { RootProvider } from './root'
|
import { RootProvider } from './root'
|
||||||
import { Alert } from 'react-bootstrap'
|
import { Alert } from 'react-bootstrap'
|
||||||
import styles from './notifications.module.css'
|
import styles from './notifications.module.css'
|
||||||
|
import { useServiceWorker } from './serviceworker'
|
||||||
|
import { Checkbox, Form } from './form'
|
||||||
|
|
||||||
function Notification ({ n }) {
|
function Notification ({ n }) {
|
||||||
switch (n.__typename) {
|
switch (n.__typename) {
|
||||||
@ -254,13 +256,16 @@ function Reply ({ n }) {
|
|||||||
|
|
||||||
function NotificationAlert () {
|
function NotificationAlert () {
|
||||||
const [showAlert, setShowAlert] = useState(false)
|
const [showAlert, setShowAlert] = useState(false)
|
||||||
const pushNotify = useNotification()
|
const [hasSubscription, setHasSubscription] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const sw = useServiceWorker()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// basically, we only want to show the alert if the user hasn't interacted with
|
const isSupported = sw.support.serviceWorker && sw.support.pushManager && sw.support.notification
|
||||||
// either opt-in of the double opt-in
|
const isDefaultPermission = sw.permission.notification === 'default'
|
||||||
setShowAlert(pushNotify.isDefault && !localStorage.getItem('hideNotifyPrompt'))
|
setShowAlert(isSupported && isDefaultPermission && !localStorage.getItem('hideNotifyPrompt'))
|
||||||
}, [pushNotify])
|
sw.registration?.pushManager.getSubscription().then(subscription => setHasSubscription(!!subscription))
|
||||||
|
}, [sw])
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
localStorage.setItem('hideNotifyPrompt', 'yep')
|
localStorage.setItem('hideNotifyPrompt', 'yep')
|
||||||
@ -268,22 +273,37 @@ function NotificationAlert () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
showAlert
|
error
|
||||||
? (
|
? (
|
||||||
<Alert variant='success' dismissible onClose={close}>
|
<Alert variant='danger' dismissible onClose={() => setError(null)}>
|
||||||
<span className='align-middle'>Enable push notifications?</span>
|
<span>{error.toString()}</span>
|
||||||
<button
|
|
||||||
className={`${styles.alertBtn} mx-1`}
|
|
||||||
onClick={() => {
|
|
||||||
pushNotify.requestPermission()
|
|
||||||
close()
|
|
||||||
}}
|
|
||||||
>Yes
|
|
||||||
</button>
|
|
||||||
<button className={`${styles.alertBtn}`} onClick={close}>No</button>
|
|
||||||
</Alert>
|
</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>
|
</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'),
|
gift: intValidator.positive('must be greater than 0').required('required'),
|
||||||
limit: intValidator.positive('must be positive')
|
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 { withPlausibleProxy } = require('next-plausible')
|
||||||
const withPWA = require('next-pwa')
|
const { InjectManifest } = require('workbox-webpack-plugin')
|
||||||
const defaultRuntimeCaching = require('next-pwa/cache')
|
const { generatePrecacheManifest } = require('./sw/build')
|
||||||
|
|
||||||
const isProd = process.env.NODE_ENV === 'production'
|
const isProd = process.env.NODE_ENV === 'production'
|
||||||
const corsHeaders = [
|
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
|
? 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)
|
: require('child_process').execSync('git rev-parse HEAD').toString().slice(0, 4)
|
||||||
|
|
||||||
module.exports = withPWA({
|
module.exports = withPlausibleProxy()({
|
||||||
dest: 'public',
|
env: {
|
||||||
register: true,
|
NEXT_PUBLIC_COMMIT_HASH: commitHash
|
||||||
customWorkerDir: 'sw',
|
},
|
||||||
runtimeCaching: [
|
compress: false,
|
||||||
{
|
experimental: {
|
||||||
urlPattern: /\/_next\/data\/.+\/.+\.json$/i,
|
scrollRestoration: true
|
||||||
handler: 'NetworkFirst',
|
},
|
||||||
options: {
|
generateBuildId: isProd ? async () => commitHash : undefined,
|
||||||
cacheName: 'next-data',
|
// Use the CDN in production and localhost for development.
|
||||||
expiration: {
|
assetPrefix: isProd ? 'https://a.stacker.news' : undefined,
|
||||||
maxEntries: 32,
|
async headers () {
|
||||||
maxAgeSeconds: 24 * 60 * 60 // 24 hours
|
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')
|
},
|
||||||
]
|
async rewrites () {
|
||||||
})(
|
return [
|
||||||
withPlausibleProxy()({
|
{
|
||||||
env: {
|
source: '/faq',
|
||||||
NEXT_PUBLIC_COMMIT_HASH: commitHash
|
destination: '/items/349'
|
||||||
},
|
},
|
||||||
compress: false,
|
{
|
||||||
experimental: {
|
source: '/story',
|
||||||
scrollRestoration: true
|
destination: '/items/1620'
|
||||||
},
|
},
|
||||||
generateBuildId: isProd ? async () => commitHash : undefined,
|
{
|
||||||
// Use the CDN in production and localhost for development.
|
source: '/privacy',
|
||||||
assetPrefix: isProd ? 'https://a.stacker.news' : undefined,
|
destination: '/items/76894'
|
||||||
async headers () {
|
},
|
||||||
return [
|
{
|
||||||
{
|
source: '/changes',
|
||||||
source: '/_next/:asset*',
|
destination: '/items/78763'
|
||||||
headers: corsHeaders
|
},
|
||||||
},
|
{
|
||||||
{
|
source: '/guide',
|
||||||
source: '/Lightningvolt-xoqm.ttf',
|
destination: '/items/81862'
|
||||||
headers: [
|
},
|
||||||
...corsHeaders,
|
{
|
||||||
{
|
source: '/daily',
|
||||||
key: 'Cache-Control',
|
destination: '/api/daily'
|
||||||
value: 'public, max-age=31536000, immutable'
|
},
|
||||||
}
|
{
|
||||||
]
|
source: '/.well-known/lnurlp/:username',
|
||||||
},
|
destination: '/api/lnurlp/:username'
|
||||||
{
|
},
|
||||||
source: '/.well-known/:slug*',
|
{
|
||||||
headers: [
|
source: '/.well-known/nostr.json',
|
||||||
...corsHeaders
|
destination: '/api/nostr/nip05'
|
||||||
]
|
},
|
||||||
},
|
{
|
||||||
{
|
source: '/.well-known/web-app-origin-association',
|
||||||
source: '/api/lnauth',
|
destination: '/api/web-app-origin-association'
|
||||||
headers: [
|
},
|
||||||
...corsHeaders
|
{
|
||||||
]
|
source: '/~:sub',
|
||||||
},
|
destination: '/~/:sub'
|
||||||
{
|
},
|
||||||
source: '/api/lnurlp/:slug*',
|
{
|
||||||
headers: [
|
source: '/~:sub/:slug*',
|
||||||
...corsHeaders
|
destination: '/~/:sub/:slug*'
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
]
|
},
|
||||||
},
|
async redirects () {
|
||||||
async rewrites () {
|
return [
|
||||||
return [
|
{
|
||||||
{
|
source: '/statistics',
|
||||||
source: '/faq',
|
destination: '/satistics?inc=invoice,withdrawal',
|
||||||
destination: '/items/349'
|
permanent: true
|
||||||
},
|
}
|
||||||
{
|
]
|
||||||
source: '/story',
|
},
|
||||||
destination: '/items/1620'
|
webpack: (config, { isServer }) => {
|
||||||
},
|
if (isServer) {
|
||||||
{
|
generatePrecacheManifest()
|
||||||
source: '/privacy',
|
config.plugins.push(
|
||||||
destination: '/items/76894'
|
new InjectManifest({
|
||||||
},
|
// ignore the precached manifest which includes the webpack assets
|
||||||
{
|
// since they are not useful to us
|
||||||
source: '/changes',
|
exclude: [/.*/],
|
||||||
destination: '/items/78763'
|
// by default, webpack saves service worker at .next/server/
|
||||||
},
|
swDest: '../../public/sw.js',
|
||||||
{
|
swSrc: './sw/index.js'
|
||||||
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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
})
|
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": "^12.3.2",
|
||||||
"next-auth": "^3.29.10",
|
"next-auth": "^3.29.10",
|
||||||
"next-plausible": "^3.6.4",
|
"next-plausible": "^3.6.4",
|
||||||
"next-pwa": "^5.6.0",
|
|
||||||
"next-seo": "^4.29.0",
|
"next-seo": "^4.29.0",
|
||||||
"nextjs-progressbar": "0.0.16",
|
"nextjs-progressbar": "0.0.16",
|
||||||
"node-s3-url-encode": "^0.0.4",
|
"node-s3-url-encode": "^0.0.4",
|
||||||
@ -82,7 +81,14 @@
|
|||||||
"url-unshort": "^6.1.0",
|
"url-unshort": "^6.1.0",
|
||||||
"use-dark-mode": "^2.3.1",
|
"use-dark-mode": "^2.3.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
|
"web-push": "^3.6.2",
|
||||||
"webln": "^0.2.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"
|
"yup": "^0.32.11"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
@ -13,8 +13,8 @@ import Moon from '../svgs/moon-fill.svg'
|
|||||||
import Layout from '../components/layout'
|
import Layout from '../components/layout'
|
||||||
import { ShowModalProvider } from '../components/modal'
|
import { ShowModalProvider } from '../components/modal'
|
||||||
import ErrorBoundary from '../components/error-boundary'
|
import ErrorBoundary from '../components/error-boundary'
|
||||||
import { NotificationProvider } from '../components/notifications'
|
|
||||||
import { FireworksProvider } from '../components/fireworks'
|
import { FireworksProvider } from '../components/fireworks'
|
||||||
|
import { ServiceWorkerProvider } from '../components/serviceworker'
|
||||||
|
|
||||||
function CSRWrapper ({ Component, apollo, ...props }) {
|
function CSRWrapper ({ Component, apollo, ...props }) {
|
||||||
const { data, error } = useQuery(gql`${apollo.query}`, { variables: apollo.variables, fetchPolicy: 'cache-first' })
|
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}>
|
<Provider session={session}>
|
||||||
<ApolloProvider client={client}>
|
<ApolloProvider client={client}>
|
||||||
<MeProvider me={me}>
|
<MeProvider me={me}>
|
||||||
<NotificationProvider>
|
<ServiceWorkerProvider>
|
||||||
<PriceProvider price={price}>
|
<PriceProvider price={price}>
|
||||||
<FireworksProvider>
|
<FireworksProvider>
|
||||||
<ShowModalProvider>
|
<ShowModalProvider>
|
||||||
@ -99,7 +99,7 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
|
|||||||
</ShowModalProvider>
|
</ShowModalProvider>
|
||||||
</FireworksProvider>
|
</FireworksProvider>
|
||||||
</PriceProvider>
|
</PriceProvider>
|
||||||
</NotificationProvider>
|
</ServiceWorkerProvider>
|
||||||
</MeProvider>
|
</MeProvider>
|
||||||
</ApolloProvider>
|
</ApolloProvider>
|
||||||
</Provider>
|
</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[]
|
Bookmarks Bookmark[]
|
||||||
Subscriptions Subscription[]
|
Subscriptions Subscription[]
|
||||||
ThreadSubscriptions ThreadSubscription[]
|
ThreadSubscriptions ThreadSubscription[]
|
||||||
|
PushSubscriptions PushSubscription[]
|
||||||
|
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([inviteId])
|
@@index([inviteId])
|
||||||
@ -579,3 +580,15 @@ model ThreadSubscription {
|
|||||||
@@id([userId, itemId])
|
@@id([userId, itemId])
|
||||||
@@index([createdAt])
|
@@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",
|
"type": "image/png",
|
||||||
"sizes": "512x512"
|
"sizes": "512x512"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-96x96.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "96x96"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"src": "/android-chrome-24x24.png",
|
"src": "/android-chrome-24x24.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
@ -26,5 +31,14 @@
|
|||||||
{
|
{
|
||||||
"origin": "https://stacker.news"
|
"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
|
import { precacheAndRoute } from 'workbox-precaching'
|
||||||
// self.__WB_DISABLE_DEV_LOGS = true
|
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