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/*.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

View File

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

View File

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

View File

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

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-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",

View File

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

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",
"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"
}
]
}

View File

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

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