diff --git a/src/routes/_actions/showShareDialogIfNecessary.js b/src/routes/_actions/showComposeDialog.js similarity index 66% rename from src/routes/_actions/showShareDialogIfNecessary.js rename to src/routes/_actions/showComposeDialog.js index f86aeed5..da167191 100644 --- a/src/routes/_actions/showShareDialogIfNecessary.js +++ b/src/routes/_actions/showComposeDialog.js @@ -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) diff --git a/src/routes/_store/observers/loggedInObservers.js b/src/routes/_store/observers/loggedInObservers.js index ec2232bc..99de70ba 100644 --- a/src/routes/_store/observers/loggedInObservers.js +++ b/src/routes/_store/observers/loggedInObservers.js @@ -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() } diff --git a/src/routes/_store/observers/showShareDialogObservers.js b/src/routes/_store/observers/showComposeDialogObservers.js similarity index 50% rename from src/routes/_store/observers/showShareDialogObservers.js rename to src/routes/_store/observers/showComposeDialogObservers.js index afd8461f..2287ca2c 100644 --- a/src/routes/_store/observers/showShareDialogObservers.js +++ b/src/routes/_store/observers/showComposeDialogObservers.js @@ -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() } } }) diff --git a/src/service-worker.js b/src/service-worker.js index 6fb5c8c2..ecb6f338 100644 --- a/src/service-worker.js +++ b/src/service-worker.js @@ -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/ ) } diff --git a/src/static/icon-shortcut-fa-bell.xcf b/src/static/icon-shortcut-fa-bell.xcf new file mode 100644 index 00000000..1bd04c62 Binary files /dev/null and b/src/static/icon-shortcut-fa-bell.xcf differ diff --git a/src/static/icon-shortcut-fa-pencil.xcf b/src/static/icon-shortcut-fa-pencil.xcf new file mode 100644 index 00000000..7a5a5663 Binary files /dev/null and b/src/static/icon-shortcut-fa-pencil.xcf differ diff --git a/static/icon-shortcut-fa-bell.png b/static/icon-shortcut-fa-bell.png new file mode 100644 index 00000000..07c4f854 Binary files /dev/null and b/static/icon-shortcut-fa-bell.png differ diff --git a/static/icon-shortcut-fa-pencil.png b/static/icon-shortcut-fa-pencil.png new file mode 100644 index 00000000..5863c705 Binary files /dev/null and b/static/icon-shortcut-fa-pencil.png differ diff --git a/static/manifest.json b/static/manifest.json index eda10151..99eaa855 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -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", diff --git a/tests/spec/027-web-share-and-web-shortcuts.js b/tests/spec/027-web-share-and-web-shortcuts.js new file mode 100644 index 00000000..0ba28678 --- /dev/null +++ b/tests/spec/027-web-share-and-web-shortcuts.js @@ -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') +}) diff --git a/tests/utils.js b/tests/utils.js index 3c21394d..22857142 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -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() }, {