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 { database } from '../_database/database'
import { doMediaUpload } from './media' 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() const { isUserLoggedIn } = store.get()
if (!isUserLoggedIn) { if (!isUserLoggedIn) {
return return
} }
const importShowComposeDialogPromise = importShowComposeDialog() // start promise early
const data = await database.getWebShareData() 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) 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 // url is currently ignored on Android, but one can dream
// https://web.dev/web-share-target/#verifying-shared-content // https://web.dev/web-share-target/#verifying-shared-content
@ -30,6 +28,7 @@ export async function showShareDialogIfNecessary () {
store.setComposeData('dialog', { text: composeText }) store.setComposeData('dialog', { text: composeText })
store.save() store.save()
const showComposeDialog = await importShowComposeDialogPromise
showComposeDialog() showComposeDialog()
if (file) { // start the upload once the dialog is in view so it shows the loading spinner and everything if (file) { // start the upload once the dialog is in view so it shows the loading spinner and everything
/* no await */ doMediaUpload('dialog', file) /* no await */ doMediaUpload('dialog', file)

View File

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

View File

@ -1,18 +1,18 @@
import { store } from '../store' 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 // 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. // in IndexedDB, then we want to handle it on the home page.
export function showShareDialogObservers () { export function showComposeDialogObservers () {
let observedOnce = false let observedOnce = false
store.observe('currentVerifyCredentials', verifyCredentials => { store.observe('currentVerifyCredentials', async verifyCredentials => {
if (verifyCredentials && !observedOnce) { if (verifyCredentials && !observedOnce) {
// when the verifyCredentials object is available, we can check to see // 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 observedOnce = true
const { currentPage } = store.get() const { currentPage } = store.get()
if (currentPage === 'home') { if (currentPage === 'home' && new URLSearchParams(location.search).get('compose') === 'true') {
/* no await */ showShareDialogIfNecessary() await showComposeDialog()
} }
} }
}) })

View File

@ -116,7 +116,7 @@ self.addEventListener('fetch', event => {
await setWebShareData({ title, text, url, file }) await setWebShareData({ title, text, url, file })
await closeKeyValIDBConnection() // don't need to keep the IDB connection open await closeKeyValIDBConnection() // don't need to keep the IDB connection open
return Response.redirect( 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/ 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" "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": [ "screenshots": [
{ {
"src": "screenshot-540-720-1.png", "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(() => { export const focus = (selector) => (exec(() => {
document.querySelector(selector).focus() document.querySelector(selector).focus()
}, { }, {