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:
ekzyis 2023-06-01 00:28:33 +02:00 committed by GitHub
parent 0de134309c
commit e97509eea7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 3995 additions and 241 deletions

8
.gitignore vendored
View File

@ -42,3 +42,11 @@ envbak
.elasticbeanstalk/* .elasticbeanstalk/*
!.elasticbeanstalk/*.cfg.yml !.elasticbeanstalk/*.cfg.yml
!.elasticbeanstalk/*.global.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

View File

@ -18,6 +18,8 @@ import LightningIcon from '../svgs/bolt.svg'
import CowboyHat from './cowboy-hat' 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 { useNotification } from './notifications'
function WalletSummary ({ me }) { function WalletSummary ({ me }) {
if (!me) return null if (!me) return null
@ -25,6 +27,14 @@ function WalletSummary ({ me }) {
return `${abbrNum(me.sats)}` 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 }) { export default function Header ({ sub }) {
const router = useRouter() const router = useRouter()
const [fired, setFired] = useState() const [fired, setFired] = useState()
@ -33,6 +43,7 @@ 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 ['','']
@ -52,7 +63,20 @@ export default function Header ({ sub }) {
{ {
hasNewNotes 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()) // const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime())
// useEffect(() => { // useEffect(() => {
// if (me) { // if (me) {
@ -254,6 +278,7 @@ export default function Header ({ sub }) {
activeKey={topNavKey} activeKey={topNavKey}
> >
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
<Back />
<Link href='/' passHref> <Link href='/' passHref>
<Navbar.Brand className={`${styles.brand} d-flex`}> <Navbar.Brand className={`${styles.brand} d-flex`}>
SN SN
@ -300,6 +325,7 @@ export function HeaderStatic () {
className={styles.navbarNav} className={styles.navbarNav}
> >
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
<Back />
<Link href='/' passHref> <Link href='/' passHref>
<Navbar.Brand className={`${styles.brand}`}> <Navbar.Brand className={`${styles.brand}`}>
SN SN

View File

@ -1,3 +1,4 @@
import { useState, useCallback, useEffect, useContext, createContext } 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'
@ -15,6 +16,7 @@ import { COMMENT_DEPTH_LIMIT } from '../lib/constants'
import CowboyHatIcon from '../svgs/cowboy.svg' import CowboyHatIcon from '../svgs/cowboy.svg'
import BaldIcon from '../svgs/bald.svg' import BaldIcon from '../svgs/bald.svg'
import { RootProvider } from './root' import { RootProvider } from './root'
import { useMe } from './me'
// TODO: oh man, this is a mess ... each notification type should just be a component ... // TODO: oh man, this is a mess ... each notification type should just be a component ...
function Notification ({ n }) { function Notification ({ n }) {
@ -223,3 +225,46 @@ 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 [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)
}

View File

@ -1,4 +1,5 @@
const { withPlausibleProxy } = require('next-plausible') const { withPlausibleProxy } = require('next-plausible')
const withPWA = require('next-pwa')
const isProd = process.env.NODE_ENV === 'production' const isProd = process.env.NODE_ENV === 'production'
const corsHeaders = [ const corsHeaders = [
@ -12,104 +13,114 @@ const corsHeaders = [
} }
] ]
module.exports = withPlausibleProxy()({ module.exports = withPWA({
compress: false, dest: 'public',
experimental: { register: true,
scrollRestoration: true customWorkerDir: 'sw'
}, })(
generateBuildId: process.env.NODE_ENV === 'development' withPlausibleProxy()({
? undefined compress: false,
: async () => { experimental: {
// use the app version which eb doesn't otherwise give us scrollRestoration: true
// as the build id
const { RuntimeSources } = require('/opt/elasticbeanstalk/deployment/app_version_manifest.json') // eslint-disable-line
return Object.keys(RuntimeSources['stacker.news'])[0]
}, },
// Use the CDN in production and localhost for development. generateBuildId: process.env.NODE_ENV === 'development'
assetPrefix: isProd ? 'https://a.stacker.news' : undefined, ? undefined
async headers () { : async () => {
return [ // use the app version which eb doesn't otherwise give us
{ // as the build id
source: '/_next/:asset*', const { RuntimeSources } = require('/opt/elasticbeanstalk/deployment/app_version_manifest.json') // eslint-disable-line
headers: corsHeaders return Object.keys(RuntimeSources['stacker.news'])[0]
}, },
{ // Use the CDN in production and localhost for development.
source: '/Lightningvolt-xoqm.ttf', assetPrefix: isProd ? 'https://a.stacker.news' : undefined,
headers: [ async headers () {
...corsHeaders, return [
{ {
key: 'Cache-Control', source: '/_next/:asset*',
value: 'public, max-age=31536000, immutable' headers: corsHeaders
} },
] {
}, source: '/Lightningvolt-xoqm.ttf',
{ headers: [
source: '/.well-known/:slug*', ...corsHeaders,
headers: [ {
...corsHeaders key: 'Cache-Control',
] value: 'public, max-age=31536000, immutable'
}, }
{ ]
source: '/api/lnauth', },
headers: [ {
...corsHeaders source: '/.well-known/:slug*',
] headers: [
}, ...corsHeaders
{ ]
source: '/api/lnurlp/:slug*', },
headers: [ {
...corsHeaders source: '/api/lnauth',
] headers: [
} ...corsHeaders
] ]
}, },
async rewrites () { {
return [ source: '/api/lnurlp/:slug*',
{ headers: [
source: '/faq', ...corsHeaders
destination: '/items/349' ]
}, }
{ ]
source: '/story', },
destination: '/items/1620' async rewrites () {
}, return [
{ {
source: '/privacy', source: '/faq',
destination: '/items/76894' destination: '/items/349'
}, },
{ {
source: '/changes', source: '/story',
destination: '/items/78763' destination: '/items/1620'
}, },
{ {
source: '/guide', source: '/privacy',
destination: '/items/81862' destination: '/items/76894'
}, },
{ {
source: '/.well-known/lnurlp/:username', source: '/changes',
destination: '/api/lnurlp/:username' destination: '/items/78763'
}, },
{ {
source: '/.well-known/nostr.json', source: '/guide',
destination: '/api/nostr/nip05' destination: '/items/81862'
}, },
{ {
source: '/~:sub', source: '/.well-known/lnurlp/:username',
destination: '/~/:sub' destination: '/api/lnurlp/:username'
}, },
{ {
source: '/~:sub/:slug*', source: '/.well-known/nostr.json',
destination: '/~/:sub/:slug*' destination: '/api/nostr/nip05'
} },
] {
}, source: '/.well-known/web-app-origin-association',
async redirects () { destination: '/api/web-app-origin-association'
return [ },
{ {
source: '/statistics', source: '/~:sub',
destination: '/satistics?inc=invoice,withdrawal', destination: '/~/:sub'
permanent: true },
} {
] source: '/~:sub/:slug*',
} destination: '/~/:sub/:slug*'
}) }
]
},
async redirects () {
return [
{
source: '/statistics',
destination: '/satistics?inc=invoice,withdrawal',
permanent: true
}
]
}
})
)

3871
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,7 @@
"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",

View File

@ -14,6 +14,7 @@ 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'
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' })
@ -88,15 +89,17 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
<Provider session={session}> <Provider session={session}>
<ApolloProvider client={client}> <ApolloProvider client={client}>
<MeProvider me={me}> <MeProvider me={me}>
<PriceProvider price={price}> <NotificationProvider>
<LightningProvider> <PriceProvider price={price}>
<ShowModalProvider> <LightningProvider>
{data || !apollo?.query <ShowModalProvider>
? <Component {...props} /> {data || !apollo?.query
: <CSRWrapper Component={Component} {...props} />} ? <Component {...props} />
</ShowModalProvider> : <CSRWrapper Component={Component} {...props} />}
</LightningProvider> </ShowModalProvider>
</PriceProvider> </LightningProvider>
</PriceProvider>
</NotificationProvider>
</MeProvider> </MeProvider>
</ApolloProvider> </ApolloProvider>
</Provider> </Provider>

12
pages/_offline.js Normal file
View File

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

View File

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

View File

@ -11,10 +11,20 @@
"src": "/android-chrome-512x512.png", "src": "/android-chrome-512x512.png",
"type": "image/png", "type": "image/png",
"sizes": "512x512" "sizes": "512x512"
},
{
"src": "/android-chrome-24x24.png",
"type": "image/png",
"sizes": "24x24"
} }
], ],
"display": "minimal-ui", "display": "standalone",
"theme_color": "#000000", "theme_color": "#000000",
"background_color": "#ffffff", "background_color": "#ffffff",
"start_url": "/" "start_url": "/",
"url_handlers": [
{
"origin": "https://stacker.news"
}
]
} }

View File

@ -61,6 +61,20 @@ $tooltip-bg: #5c8001;
.table-sm td { .table-sm td {
padding: .3rem .75rem; padding: .3rem .75rem;
} }
.standalone {
// undo the container padding
margin-left: -16px;
}
}
.standalone {
display: none;
}
@media (display-mode: standalone) {
.standalone {
display: flex
}
} }
.line-height-1 { .line-height-1 {

2
sw/index.js Normal file
View File

@ -0,0 +1,2 @@
// Uncomment to disable workbox logging during development
// self.__WB_DISABLE_DEV_LOGS = true