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
8
.gitignore
vendored
8
.gitignore
vendored
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
@ -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,7 +13,12 @@ const corsHeaders = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
module.exports = withPlausibleProxy()({
|
module.exports = withPWA({
|
||||||
|
dest: 'public',
|
||||||
|
register: true,
|
||||||
|
customWorkerDir: 'sw'
|
||||||
|
})(
|
||||||
|
withPlausibleProxy()({
|
||||||
compress: false,
|
compress: false,
|
||||||
experimental: {
|
experimental: {
|
||||||
scrollRestoration: true
|
scrollRestoration: true
|
||||||
@ -93,6 +99,10 @@ module.exports = withPlausibleProxy()({
|
|||||||
source: '/.well-known/nostr.json',
|
source: '/.well-known/nostr.json',
|
||||||
destination: '/api/nostr/nip05'
|
destination: '/api/nostr/nip05'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: '/.well-known/web-app-origin-association',
|
||||||
|
destination: '/api/web-app-origin-association'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: '/~:sub',
|
source: '/~:sub',
|
||||||
destination: '/~/:sub'
|
destination: '/~/:sub'
|
||||||
@ -113,3 +123,4 @@ module.exports = withPlausibleProxy()({
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
3871
package-lock.json
generated
3871
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||||
|
@ -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,6 +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>
|
||||||
<PriceProvider price={price}>
|
<PriceProvider price={price}>
|
||||||
<LightningProvider>
|
<LightningProvider>
|
||||||
<ShowModalProvider>
|
<ShowModalProvider>
|
||||||
@ -97,6 +99,7 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
|
|||||||
</ShowModalProvider>
|
</ShowModalProvider>
|
||||||
</LightningProvider>
|
</LightningProvider>
|
||||||
</PriceProvider>
|
</PriceProvider>
|
||||||
|
</NotificationProvider>
|
||||||
</MeProvider>
|
</MeProvider>
|
||||||
</ApolloProvider>
|
</ApolloProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
|
12
pages/_offline.js
Normal file
12
pages/_offline.js
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
11
pages/api/web-app-origin-association.js
Normal file
11
pages/api/web-app-origin-association.js
Normal 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: []
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
BIN
public/android-chrome-24x24.png
Normal file
BIN
public/android-chrome-24x24.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.7 KiB |
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
@ -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
2
sw/index.js
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// Uncomment to disable workbox logging during development
|
||||||
|
// self.__WB_DISABLE_DEV_LOGS = true
|
Loading…
x
Reference in New Issue
Block a user