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:
parent
65733ce68a
commit
d044e12aee
|
@ -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)
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
|
@ -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/
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
BIN
src/static/icon-shortcut-fa-bell.xcf
Normal file
BIN
src/static/icon-shortcut-fa-bell.xcf
Normal file
Binary file not shown.
BIN
src/static/icon-shortcut-fa-pencil.xcf
Normal file
BIN
src/static/icon-shortcut-fa-pencil.xcf
Normal file
Binary file not shown.
BIN
static/icon-shortcut-fa-bell.png
Normal file
BIN
static/icon-shortcut-fa-bell.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.1 KiB |
BIN
static/icon-shortcut-fa-pencil.png
Normal file
BIN
static/icon-shortcut-fa-pencil.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.4 KiB |
|
@ -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",
|
||||||
|
|
44
tests/spec/027-web-share-and-web-shortcuts.js
Normal file
44
tests/spec/027-web-share-and-web-shortcuts.js
Normal 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')
|
||||||
|
})
|
|
@ -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()
|
||||||
}, {
|
}, {
|
||||||
|
|
Loading…
Reference in a new issue