feat: add PWA shortcuts for compose/notifications (#2019)

* feat: add PWA shortcuts for compose/notifications

Fixes #2012

* fix: fix icon path
This commit is contained in:
Nolan Lawson 2021-03-21 13:49:59 -07:00 committed by GitHub
parent 65733ce68a
commit d044e12aee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 132 additions and 19 deletions

View file

@ -3,24 +3,22 @@ import { importShowComposeDialog } from '../_components/dialog/asyncDialogs/impo
import { database } from '../_database/database'
import { doMediaUpload } from './media'
export async function showShareDialogIfNecessary () {
// show a compose dialog, typically invoked by the Web Share API or a PWA shortcut
export async function showComposeDialog () {
const { isUserLoggedIn } = store.get()
if (!isUserLoggedIn) {
return
}
const importShowComposeDialogPromise = importShowComposeDialog() // start promise early
const data = await database.getWebShareData()
if (!data) {
return
if (data) {
await database.deleteWebShareData() // only need this data once; it came from Web Share (service worker)
}
// delete from IDB and import the dialog in parallel
const [showComposeDialog] = await Promise.all([
importShowComposeDialog(),
database.deleteWebShareData()
])
console.log('share data', data)
const { title, text, url, file } = data
const { title, text, url, file } = (data || {})
// url is currently ignored on Android, but one can dream
// https://web.dev/web-share-target/#verifying-shared-content
@ -30,6 +28,7 @@ export async function showShareDialogIfNecessary () {
store.setComposeData('dialog', { text: composeText })
store.save()
const showComposeDialog = await importShowComposeDialogPromise
showComposeDialog()
if (file) { // start the upload once the dialog is in view so it shows the loading spinner and everything
/* no await */ doMediaUpload('dialog', file)

View file

@ -7,7 +7,7 @@ import { customScrollbarObservers } from './customScrollbarObservers'
import { customEmojiObservers } from './customEmojiObservers'
import { cleanup } from './cleanup'
import { wordFilterObservers } from './wordFilterObservers'
import { showShareDialogObservers } from './showShareDialogObservers'
import { showComposeDialogObservers } from './showComposeDialogObservers'
import { badgeObservers } from './badgeObservers'
// These observers can be lazy-loaded when the user is actually logged in.
@ -21,7 +21,7 @@ export function loggedInObservers () {
notificationPermissionObservers()
customScrollbarObservers()
customEmojiObservers()
showShareDialogObservers()
showComposeDialogObservers()
badgeObservers()
cleanup()
}

View file

@ -1,18 +1,18 @@
import { store } from '../store'
import { showShareDialogIfNecessary } from '../../_actions/showShareDialogIfNecessary'
import { showComposeDialog } from '../../_actions/showComposeDialog'
// If the user is logged in, and if the Service Worker handled a POST and set special data
// in IndexedDB, then we want to handle it on the home page.
export function showShareDialogObservers () {
export function showComposeDialogObservers () {
let observedOnce = false
store.observe('currentVerifyCredentials', verifyCredentials => {
store.observe('currentVerifyCredentials', async verifyCredentials => {
if (verifyCredentials && !observedOnce) {
// when the verifyCredentials object is available, we can check to see
// if the user is trying to share something, then share it
// if the user is trying to share something (or we got here from a shortcut), then share it
observedOnce = true
const { currentPage } = store.get()
if (currentPage === 'home') {
/* no await */ showShareDialogIfNecessary()
if (currentPage === 'home' && new URLSearchParams(location.search).get('compose') === 'true') {
await showComposeDialog()
}
}
})

View file

@ -116,7 +116,7 @@ self.addEventListener('fetch', event => {
await setWebShareData({ title, text, url, file })
await closeKeyValIDBConnection() // don't need to keep the IDB connection open
return Response.redirect(
'/?pwa=true', // same as start_url in manifest.json. This can only be invoked from PWAs
'/?pwa=true&compose=true', // pwa=true because this can only be invoked from a PWA
303 // 303 recommended by https://web.dev/web-share-target/
)
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -112,6 +112,32 @@
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Write a toot",
"short_name": "New toot",
"description": "Start composing a new toot",
"url": "/?pwa=true&compose=true",
"icons": [
{
"src": "/icon-shortcut-fa-pencil.png",
"sizes": "192x192"
}
]
},
{
"name": "View notifications",
"short_name": "Notifications",
"description": "View your new notifications",
"url": "/notifications?pwa=true",
"icons": [
{
"src": "/icon-shortcut-fa-bell.png",
"sizes": "192x192"
}
]
}
],
"screenshots": [
{
"src": "screenshot-540-720-1.png",

View file

@ -0,0 +1,44 @@
import {
composeModalInput, getComposeModalNthMediaListItem,
getUrl, modalDialogContents, simulateWebShare
} from '../utils'
import { loginAsFoobar } from '../roles'
import { ONE_TRANSPARENT_PIXEL } from '../../src/routes/_static/media'
fixture`027-web-share-and-web-shortcuts.js`
.page`http://localhost:4002`
test('Can take a shortcut directly to a compose dialog', async t => {
await loginAsFoobar(t)
await t
.expect(getUrl()).eql('http://localhost:4002/')
.navigateTo('http://localhost:4002/?compose=true')
.expect(modalDialogContents.exists).ok()
.expect(composeModalInput.value).eql('')
.expect(getComposeModalNthMediaListItem(1).exists).notOk()
})
test('Can share title/text using Web Share', async t => {
await loginAsFoobar(t)
await t
.expect(getUrl()).eql('http://localhost:4002/')
await (simulateWebShare({ title: 'my title', url: undefined, text: 'my text' })())
await t
.navigateTo('http://localhost:4002/?compose=true')
.expect(modalDialogContents.exists).ok()
.expect(composeModalInput.value).eql('my title\n\nmy text')
.expect(getComposeModalNthMediaListItem(1).exists).notOk()
})
test('Can share a file using Web Share', async t => {
await loginAsFoobar(t)
await t
.expect(getUrl()).eql('http://localhost:4002/')
await (simulateWebShare({ title: undefined, url: undefined, text: undefined, file: ONE_TRANSPARENT_PIXEL })())
await t
.navigateTo('http://localhost:4002/?compose=true')
.expect(modalDialogContents.exists).ok()
.expect(composeModalInput.value).eql('')
.expect(getComposeModalNthMediaListItem(1).exists).ok()
.expect(getComposeModalNthMediaListItem(1).getAttribute('aria-label')).eql('media')
})

View file

@ -265,6 +265,50 @@ export const uploadKittenImage = i => (exec(() => {
}
}))
export const simulateWebShare = ({ title, text, url, file }) => (exec(() => {
let blob
return Promise.resolve().then(() => {
if (file) {
return fetch(file).then(resp => resp.blob()).then(theBlob => {
blob = theBlob
})
}
}).then(() => {
return new Promise((resolve, reject) => {
const request = indexedDB.open('keyval-store')
request.onerror = (event) => {
console.error(event)
reject(new Error('idb error'))
}
request.onupgradeneeded = () => {
request.result.createObjectStore('keyval')
}
request.onsuccess = (event) => {
const db = event.target.result
const txn = db.transaction('keyval', 'readwrite')
txn.onerror = () => reject(new Error('idb error'))
txn.oncomplete = () => {
db.close()
resolve()
}
txn.objectStore('keyval').put({
title,
text,
url,
file: blob
}, 'web-share-data')
}
})
})
}, {
dependencies: {
title,
text,
url,
file
}
}))
export const focus = (selector) => (exec(() => {
document.querySelector(selector).focus()
}, {