Use PWA with display: standalone (#292)
* Use next-pwa * Use standalone + back button * Use Notification API * Use custom service worker * Use url_handlers * Add offline page * Use smaller icon in notification * Only prompt for notifications if logged in * small enhancements to standalone pwa * remove unused back arrow --------- Co-authored-by: ekzyis <ek@stacker.news> Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
This commit is contained in:
parent
0de134309c
commit
e97509eea7
|
@ -42,3 +42,11 @@ envbak
|
|||
.elasticbeanstalk/*
|
||||
!.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
|
||||
|
|
|
@ -18,6 +18,8 @@ import LightningIcon from '../svgs/bolt.svg'
|
|||
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'
|
||||
|
||||
function WalletSummary ({ me }) {
|
||||
if (!me) return null
|
||||
|
@ -25,6 +27,14 @@ function WalletSummary ({ me }) {
|
|||
return `${abbrNum(me.sats)}`
|
||||
}
|
||||
|
||||
function Back () {
|
||||
const router = useRouter()
|
||||
if (typeof window !== 'undefined' && (window.navigation.canGoBack === undefined || window.navigation.canGoBack)) {
|
||||
return <BackArrow className='theme standalone mr-2' width={22} height={22} onClick={() => router.back()} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default function Header ({ sub }) {
|
||||
const router = useRouter()
|
||||
const [fired, setFired] = useState()
|
||||
|
@ -33,6 +43,7 @@ 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 ['','']
|
||||
|
@ -52,7 +63,20 @@ export default function Header ({ sub }) {
|
|||
{
|
||||
hasNewNotes
|
||||
}
|
||||
`, { pollInterval: 30000, fetchPolicy: 'cache-and-network' })
|
||||
`, {
|
||||
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 new notifications')
|
||||
}
|
||||
localStorage.setItem('notified', data.hasNewNotes)
|
||||
}
|
||||
})
|
||||
// const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime())
|
||||
// useEffect(() => {
|
||||
// if (me) {
|
||||
|
@ -254,6 +278,7 @@ export default function Header ({ sub }) {
|
|||
activeKey={topNavKey}
|
||||
>
|
||||
<div className='d-flex align-items-center'>
|
||||
<Back />
|
||||
<Link href='/' passHref>
|
||||
<Navbar.Brand className={`${styles.brand} d-flex`}>
|
||||
SN
|
||||
|
@ -300,6 +325,7 @@ export function HeaderStatic () {
|
|||
className={styles.navbarNav}
|
||||
>
|
||||
<div className='d-flex align-items-center'>
|
||||
<Back />
|
||||
<Link href='/' passHref>
|
||||
<Navbar.Brand className={`${styles.brand}`}>
|
||||
SN
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { useState, useCallback, useEffect, useContext, createContext } from 'react'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import Comment, { CommentSkeleton } from './comment'
|
||||
import Item from './item'
|
||||
|
@ -15,6 +16,7 @@ import { COMMENT_DEPTH_LIMIT } from '../lib/constants'
|
|||
import CowboyHatIcon from '../svgs/cowboy.svg'
|
||||
import BaldIcon from '../svgs/bald.svg'
|
||||
import { RootProvider } from './root'
|
||||
import { useMe } from './me'
|
||||
|
||||
// TODO: oh man, this is a mess ... each notification type should just be a component ...
|
||||
function Notification ({ n }) {
|
||||
|
@ -223,3 +225,46 @@ function CommentsFlatSkeleton () {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NotificationContext = createContext({})
|
||||
|
||||
export const NotificationProvider = ({ children }) => {
|
||||
const isBrowser = typeof window !== 'undefined'
|
||||
const [isSupported] = useState(isBrowser ? 'Notification' in window : false)
|
||||
const [isDefaultPermission, setIsDefaultPermission] = useState(isSupported ? window.Notification.permission === 'default' : undefined)
|
||||
const [isGranted, setIsGranted] = useState(isSupported ? window.Notification.permission === 'granted' : undefined)
|
||||
const me = useMe()
|
||||
|
||||
const show_ = (title, options) => {
|
||||
const icon = '/android-chrome-24x24.png'
|
||||
new window.Notification(title, { icon, ...options })
|
||||
}
|
||||
|
||||
const show = useCallback((...args) => {
|
||||
if (!isGranted) return
|
||||
show_(...args)
|
||||
}, [isGranted])
|
||||
|
||||
const requestPermission = useCallback(() => {
|
||||
window.Notification.requestPermission().then(result => {
|
||||
setIsDefaultPermission(window.Notification.permission === 'default')
|
||||
if (result === 'granted') {
|
||||
setIsGranted(result === 'granted')
|
||||
show_('you have enabled notifications')
|
||||
}
|
||||
})
|
||||
}, [isDefaultPermission])
|
||||
|
||||
useEffect(() => {
|
||||
if (!me || !isSupported || !isDefaultPermission) return
|
||||
requestPermission()
|
||||
}, [])
|
||||
|
||||
const ctx = { isBrowser, isSupported, isDefaultPermission, isGranted, show }
|
||||
|
||||
return <NotificationContext.Provider value={ctx}>{children}</NotificationContext.Provider>
|
||||
}
|
||||
|
||||
export function useNotification () {
|
||||
return useContext(NotificationContext)
|
||||
}
|
||||
|
|
209
next.config.js
209
next.config.js
|
@ -1,4 +1,5 @@
|
|||
const { withPlausibleProxy } = require('next-plausible')
|
||||
const withPWA = require('next-pwa')
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
const corsHeaders = [
|
||||
|
@ -12,104 +13,114 @@ const corsHeaders = [
|
|||
}
|
||||
]
|
||||
|
||||
module.exports = withPlausibleProxy()({
|
||||
compress: false,
|
||||
experimental: {
|
||||
scrollRestoration: true
|
||||
},
|
||||
generateBuildId: process.env.NODE_ENV === 'development'
|
||||
? undefined
|
||||
: async () => {
|
||||
// use the app version which eb doesn't otherwise give us
|
||||
// as the build id
|
||||
const { RuntimeSources } = require('/opt/elasticbeanstalk/deployment/app_version_manifest.json') // eslint-disable-line
|
||||
return Object.keys(RuntimeSources['stacker.news'])[0]
|
||||
module.exports = withPWA({
|
||||
dest: 'public',
|
||||
register: true,
|
||||
customWorkerDir: 'sw'
|
||||
})(
|
||||
withPlausibleProxy()({
|
||||
compress: false,
|
||||
experimental: {
|
||||
scrollRestoration: true
|
||||
},
|
||||
// Use the CDN in production and localhost for development.
|
||||
assetPrefix: isProd ? 'https://a.stacker.news' : undefined,
|
||||
async headers () {
|
||||
return [
|
||||
{
|
||||
source: '/_next/:asset*',
|
||||
headers: corsHeaders
|
||||
generateBuildId: process.env.NODE_ENV === 'development'
|
||||
? undefined
|
||||
: async () => {
|
||||
// use the app version which eb doesn't otherwise give us
|
||||
// as the build id
|
||||
const { RuntimeSources } = require('/opt/elasticbeanstalk/deployment/app_version_manifest.json') // eslint-disable-line
|
||||
return Object.keys(RuntimeSources['stacker.news'])[0]
|
||||
},
|
||||
{
|
||||
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: '/.well-known/lnurlp/:username',
|
||||
destination: '/api/lnurlp/:username'
|
||||
},
|
||||
{
|
||||
source: '/.well-known/nostr.json',
|
||||
destination: '/api/nostr/nip05'
|
||||
},
|
||||
{
|
||||
source: '/~:sub',
|
||||
destination: '/~/:sub'
|
||||
},
|
||||
{
|
||||
source: '/~:sub/:slug*',
|
||||
destination: '/~/:sub/:slug*'
|
||||
}
|
||||
]
|
||||
},
|
||||
async redirects () {
|
||||
return [
|
||||
{
|
||||
source: '/statistics',
|
||||
destination: '/satistics?inc=invoice,withdrawal',
|
||||
permanent: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
// 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: '/.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
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
)
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -43,6 +43,7 @@
|
|||
"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",
|
||||
|
|
|
@ -14,6 +14,7 @@ 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'
|
||||
|
||||
function CSRWrapper ({ Component, apollo, ...props }) {
|
||||
const { data, error } = useQuery(gql`${apollo.query}`, { variables: apollo.variables, fetchPolicy: 'cache-first' })
|
||||
|
@ -88,15 +89,17 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
|
|||
<Provider session={session}>
|
||||
<ApolloProvider client={client}>
|
||||
<MeProvider me={me}>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ShowModalProvider>
|
||||
{data || !apollo?.query
|
||||
? <Component {...props} />
|
||||
: <CSRWrapper Component={Component} {...props} />}
|
||||
</ShowModalProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
<NotificationProvider>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ShowModalProvider>
|
||||
{data || !apollo?.query
|
||||
? <Component {...props} />
|
||||
: <CSRWrapper Component={Component} {...props} />}
|
||||
</ShowModalProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
</NotificationProvider>
|
||||
</MeProvider>
|
||||
</ApolloProvider>
|
||||
</Provider>
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import { Image } from 'react-bootstrap'
|
||||
import LayoutStatic from '../components/layout-static'
|
||||
import styles from '../styles/404.module.css'
|
||||
|
||||
export default function offline () {
|
||||
return (
|
||||
<LayoutStatic>
|
||||
<Image width='500' height='376' src='/falling.gif' fluid />
|
||||
<h1 className={styles.fourZeroFour}><span>Offline</span></h1>
|
||||
</LayoutStatic>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
export default function handler (req, res) {
|
||||
return res.status(200).json({
|
||||
web_apps: [{
|
||||
manifest: 'https://stacker.news/site.webmanifest',
|
||||
details: {
|
||||
paths: ['*'],
|
||||
exclude_paths: []
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 4.7 KiB |
|
@ -11,10 +11,20 @@
|
|||
"src": "/android-chrome-512x512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-24x24.png",
|
||||
"type": "image/png",
|
||||
"sizes": "24x24"
|
||||
}
|
||||
],
|
||||
"display": "minimal-ui",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff",
|
||||
"start_url": "/"
|
||||
"start_url": "/",
|
||||
"url_handlers": [
|
||||
{
|
||||
"origin": "https://stacker.news"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -61,6 +61,20 @@ $tooltip-bg: #5c8001;
|
|||
.table-sm td {
|
||||
padding: .3rem .75rem;
|
||||
}
|
||||
.standalone {
|
||||
// undo the container padding
|
||||
margin-left: -16px;
|
||||
}
|
||||
}
|
||||
|
||||
.standalone {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
.standalone {
|
||||
display: flex
|
||||
}
|
||||
}
|
||||
|
||||
.line-height-1 {
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
// Uncomment to disable workbox logging during development
|
||||
// self.__WB_DISABLE_DEV_LOGS = true
|
Loading…
Reference in New Issue