merge github master

This commit is contained in:
keyan 2023-07-04 15:35:04 -05:00
commit 3068252adf
28 changed files with 2686 additions and 7749 deletions

View File

@ -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
View 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.

View 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
View File

@ -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

View File

@ -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) {

View File

@ -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
},

View File

@ -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
View 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)
}
}

View File

@ -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 })

View File

@ -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)

View File

@ -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 {

View File

@ -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(() => {

View File

@ -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 })
}}

View File

@ -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
View 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)
}

View File

@ -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()
})

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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": {

View File

@ -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
View 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

View File

@ -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;

View File

@ -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])
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@ -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
View 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 }

View File

@ -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
)