more work on offline capabilities

This commit is contained in:
Nolan Lawson 2018-01-18 23:37:43 -08:00
parent 42fd153364
commit 6cf4a11283
19 changed files with 147 additions and 74 deletions

5
package-lock.json generated
View file

@ -3282,6 +3282,11 @@
} }
} }
}, },
"idb-keyval": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-2.3.0.tgz",
"integrity": "sha1-TURLgMP4b8vNUTIbTcvJJHxZSMA="
},
"ieee754": { "ieee754": {
"version": "1.1.8", "version": "1.1.8",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz",

View file

@ -23,6 +23,7 @@
"fg-loadcss": "^2.0.1", "fg-loadcss": "^2.0.1",
"font-awesome-svg-png": "^1.2.2", "font-awesome-svg-png": "^1.2.2",
"glob": "^7.1.2", "glob": "^7.1.2",
"idb-keyval": "^2.3.0",
"indexeddb-getall-shim": "^1.3.1", "indexeddb-getall-shim": "^1.3.1",
"intersection-observer": "^0.5.0", "intersection-observer": "^0.5.0",
"intl-relativeformat": "^2.1.0", "intl-relativeformat": "^2.1.0",

View file

@ -7,7 +7,7 @@
{{/if}} {{/if}}
</div> </div>
{{then constructor}} {{then constructor}}
<:Component {constructor} :target /> <:Component {constructor} :timeline />
{{catch error}} {{catch error}}
<div>Component failed to load. Please try refreshing! {{error}}</div> <div>Component failed to load. Please try refreshing! {{error}}</div>
{{/await}} {{/await}}

View file

@ -11,16 +11,18 @@
</style> </style>
<script> <script>
import { store } from '../_utils/store' import { store } from '../_utils/store'
import { getHomeTimeline } from '../_utils/mastodon/timelines' import { getTimeline } from '../_utils/mastodon/timelines'
import StatusListItem from './StatusListItem.html' import StatusListItem from './StatusListItem.html'
import VirtualList from './VirtualList.html' import VirtualList from './VirtualList.html'
import { splice, push } from 'svelte-extras' import { splice, push } from 'svelte-extras'
import { import {
insertStatuses as insertStatusesIntoDatabase, insertStatuses as insertStatusesIntoDatabase,
getTimelineAfter as getTimelineFromDatabaseAfter getTimeline as getTimelineFromDatabase
} from '../_utils/database/statuses' } from '../_utils/database/statuses'
import { mergeStatuses } from '../_utils/statuses' import { mergeStatuses } from '../_utils/statuses'
import { mark, stop } from '../_utils/marks' import { mark, stop } from '../_utils/marks'
import { timelines } from '../_static/timelines'
const FETCH_LIMIT = 20 const FETCH_LIMIT = 20
@ -30,15 +32,14 @@
let instanceData = this.store.get('currentInstanceData') let instanceData = this.store.get('currentInstanceData')
let online = this.get('online') let online = this.get('online')
let statuses = online ? let statuses = online ?
await getHomeTimeline(instanceName, instanceData.access_token, null, FETCH_LIMIT) : await getTimeline(instanceName, instanceData.access_token, this.get('timeline'), null, FETCH_LIMIT) :
await getTimelineFromDatabaseAfter(null, FETCH_LIMIT) await getTimelineFromDatabase(instanceName, this.get('timeline'), null, FETCH_LIMIT)
if (online) { if (online) {
insertStatusesIntoDatabase(statuses) insertStatusesIntoDatabase(instanceName, this.get('timeline'), statuses)
} }
this.addStatuses(statuses) this.addStatuses(statuses)
}, },
data: () => ({ data: () => ({
target: 'home',
StatusListItem: StatusListItem, StatusListItem: StatusListItem,
statuses: [], statuses: [],
runningUpdate: false runningUpdate: false
@ -49,7 +50,7 @@
key: status.id key: status.id
})), })),
lastStatusId: (statuses) => statuses.length && statuses[statuses.length - 1].id, lastStatusId: (statuses) => statuses.length && statuses[statuses.length - 1].id,
label: (target, $currentInstance) => `${target} timeline for ${$currentInstance}` label: (timeline, $currentInstance) => `${timelines[timeline].label} timeline for ${$currentInstance}`
}, },
store: () => store, store: () => store,
components: { components: {
@ -69,10 +70,10 @@
let instanceData = this.store.get('currentInstanceData') let instanceData = this.store.get('currentInstanceData')
let online = this.get('online') let online = this.get('online')
let newStatuses = online ? let newStatuses = online ?
await getHomeTimeline(instanceName, instanceData.access_token, lastStatusId, FETCH_LIMIT) : await getTimeline(instanceName, instanceData.access_token, this.get('timeline'), lastStatusId, FETCH_LIMIT) :
await getTimelineFromDatabaseAfter(lastStatusId, FETCH_LIMIT) await getTimelineFromDatabase(instanceName, this.get('timeline'), lastStatusId, FETCH_LIMIT)
if (online) { if (online) {
insertStatusesIntoDatabase(newStatuses) insertStatusesIntoDatabase(instanceName, this.get('timeline'), newStatuses)
} }
let statuses = this.get('statuses') let statuses = this.get('statuses')
if (statuses) { if (statuses) {

View file

@ -0,0 +1,7 @@
const timelines = {
home: { name: 'home', label: 'Home' },
local: { name: 'local', label: 'Local' },
federated: { name: 'federated', label: 'Federated' }
}
export { timelines }

View file

@ -25,15 +25,10 @@ const importIndexedDBGetAllShim = () => import(
/* webpackChunkName: 'indexeddb-getall-shim' */ 'indexeddb-getall-shim' /* webpackChunkName: 'indexeddb-getall-shim' */ 'indexeddb-getall-shim'
) )
const importOfflineNotification = () => import(
/* webpackHunkName: 'offlineNotification' */ '../_utils/offlineNotification'
)
export { export {
importURLSearchParams, importURLSearchParams,
importTimeline, importTimeline,
importIntersectionObserver, importIntersectionObserver,
importRequestIdleCallback, importRequestIdleCallback,
importIndexedDBGetAllShim, importIndexedDBGetAllShim
importOfflineNotification
} }

View file

@ -0,0 +1,2 @@
import keyval from 'idb-keyval'
export { keyval }

View file

@ -1,7 +1,9 @@
import { keyval } from './keyval'
import cloneDeep from 'lodash/cloneDeep' import cloneDeep from 'lodash/cloneDeep'
import padStart from 'lodash/padStart' import padStart from 'lodash/padStart'
const STORE = 'statuses' const STORE = 'statuses'
const databaseCache = {}
function toPaddedBigInt(id) { function toPaddedBigInt(id) {
return padStart(id, 30, '0') return padStart(id, 30, '0')
@ -24,26 +26,40 @@ function transformStatusForStorage(status) {
return status return status
} }
const dbPromise = new Promise((resolve, reject) => { function getDatabase(instanceName, timeline) {
let req = indexedDB.open(STORE, 1) const key = `${instanceName}_${timeline}`
req.onerror = reject if (databaseCache[key]) {
req.onblocked = () => { return Promise.resolve(databaseCache[key])
console.log('idb blocked')
} }
req.onupgradeneeded = () => {
let db = req.result;
let oStore = db.createObjectStore(STORE, {
keyPath: 'id'
})
oStore.createIndex('created_at', 'created_at')
oStore.createIndex('pinafore_id_as_negative_big_int', 'pinafore_id_as_negative_big_int')
oStore.createIndex('pinafore_id_as_big_int', 'pinafore_id_as_big_int')
}
req.onsuccess = () => resolve(req.result)
})
export async function getTimelineAfter(max_id = null, limit = 20) { let objectStoreName = `${STORE}_${key}`
const db = await dbPromise
keyval.get('known_dbs').then(knownDbs => {
knownDbs = knownDbs || {}
knownDbs[objectStoreName] = true
keyval.set('known_dbs', knownDbs)
})
databaseCache[key] = new Promise((resolve, reject) => {
let req = indexedDB.open(objectStoreName, 1)
req.onerror = reject
req.onblocked = () => {
console.log('idb blocked')
}
req.onupgradeneeded = () => {
let db = req.result;
let oStore = db.createObjectStore(STORE, {
keyPath: 'id'
})
oStore.createIndex('pinafore_id_as_negative_big_int', 'pinafore_id_as_negative_big_int')
}
req.onsuccess = () => resolve(req.result)
})
return databaseCache[key]
}
export async function getTimeline(instanceName, timeline, max_id = null, limit = 20) {
const db = await getDatabase(instanceName, timeline)
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly') const tx = db.transaction(STORE, 'readonly')
const store = tx.objectStore(STORE) const store = tx.objectStore(STORE)
@ -61,8 +77,8 @@ export async function getTimelineAfter(max_id = null, limit = 20) {
}) })
} }
export async function insertStatuses(statuses) { export async function insertStatuses(instanceName, timeline, statuses) {
const db = await dbPromise const db = await getDatabase(instanceName, timeline)
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite') const tx = db.transaction(STORE, 'readwrite')
const store = tx.objectStore(STORE) const store = tx.objectStore(STORE)

View file

@ -1,8 +1,9 @@
import { get, paramsString } from '../ajax' import { get, paramsString } from '../ajax'
import { basename } from './utils' import { basename } from './utils'
export function getHomeTimeline(instanceName, accessToken, maxId, since) { export function getTimeline(instanceName, accessToken, timeline, maxId, since) {
let url = `${basename(instanceName)}/api/v1/timelines/home` let timelineUrlName = timeline === 'local' || timeline === 'federated' ? 'public' : timeline
let url = `${basename(instanceName)}/api/v1/timelines/${timelineUrlName}`
let params = {} let params = {}
if (since) { if (since) {
@ -13,6 +14,10 @@ export function getHomeTimeline(instanceName, accessToken, maxId, since) {
params.max_id = maxId params.max_id = maxId
} }
if (timeline === 'local') {
params.local = true
}
url += '?' + paramsString(params) url += '?' + paramsString(params)
return get(url, { return get(url, {

View file

@ -17,5 +17,9 @@ const observe = online => {
} }
} }
if (!navigator.onLine) {
observe(false)
}
window.addEventListener('offline', () => observe(false)); window.addEventListener('offline', () => observe(false));
window.addEventListener('online', () => observe(true)); window.addEventListener('online', () => observe(true));

View file

@ -0,0 +1,22 @@
import { toast } from './toast'
import { keyval } from './database/keyval'
function onUpdateFound(registration) {
const newWorker = registration.installing
newWorker.addEventListener('statechange', async () => {
if (!(await keyval.get('serviceworker_installed'))) {
await keyval.set('serviceworker_installed', true)
return
}
if (newWorker.state === 'activated' && navigator.serviceWorker.controller) {
toast.say('App update available. Reload to update.')
}
});
}
if (!location.origin.match('localhost') && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js').then(registration => {
registration.addEventListener('updatefound', () => onUpdateFound(registration))
})
}

View file

@ -3,16 +3,29 @@
</:Head> </:Head>
<Layout page='federated'> <Layout page='federated'>
<h1>Federated Timeline</h1> {{#if $isUserLoggedIn}}
<LazyTimeline timeline='federated' />
{{else}}
<FreeTextLayout>
<h1>Federated</h1>
<p>Your federated timeline will appear here when logged in.</p>
</FreeTextLayout>
{{/if}}
</Layout> </Layout>
<script> <script>
import Layout from './_components/Layout.html'; import Layout from './_components/Layout.html'
import LazyTimeline from './_components/LazyTimeline.html'
import FreeTextLayout from './_components/FreeTextLayout.html'
import { store } from './_utils/store.js'
export default { export default {
store: () => store,
components: { components: {
Layout Layout,
LazyTimeline,
FreeTextLayout
} }
}; }
</script> </script>

View file

@ -4,7 +4,7 @@
<Layout page='home'> <Layout page='home'>
{{#if $isUserLoggedIn}} {{#if $isUserLoggedIn}}
<LazyTimeline target='home' /> <LazyTimeline timeline='home' />
{{else}} {{else}}
<NotLoggedInHome/> <NotLoggedInHome/>
{{/if}} {{/if}}

View file

@ -3,16 +3,29 @@
</:Head> </:Head>
<Layout page='local'> <Layout page='local'>
<h1>Local</h1> {{#if $isUserLoggedIn}}
<LazyTimeline timeline='local' />
{{else}}
<FreeTextLayout>
<h1>Local</h1>
<p>Your local timeline will appear here when logged in.</p>
</FreeTextLayout>
{{/if}}
</Layout> </Layout>
<script> <script>
import Layout from './_components/Layout.html'; import Layout from './_components/Layout.html'
import LazyTimeline from './_components/LazyTimeline.html'
import FreeTextLayout from './_components/FreeTextLayout.html'
import { store } from './_utils/store.js'
export default { export default {
store: () => store,
components: { components: {
Layout Layout,
LazyTimeline,
FreeTextLayout
} }
}; }
</script> </script>

View file

@ -3,16 +3,21 @@
</:Head> </:Head>
<Layout page='notifications'> <Layout page='notifications'>
<h1>Notifications</h1> <FreeTextLayout>
<h1>Notifications</h1>
<p>Your notifications will appear here when logged in.</p>
</FreeTextLayout>
</Layout> </Layout>
<script> <script>
import Layout from './_components/Layout.html'; import Layout from './_components/Layout.html';
import FreeTextLayout from './_components/FreeTextLayout.html'
export default { export default {
components: { components: {
Layout Layout,
FreeTextLayout
}, },
}; };
</script> </script>

View file

@ -16,7 +16,7 @@
<span class="acct-display-name">{{instanceUserAccount.display_name}}</span> <span class="acct-display-name">{{instanceUserAccount.display_name}}</span>
</div> </div>
<h2>Theme:</h2> <h2>Theme:</h2>
<form class="theme-chooser"> <form class="theme-chooser" aria-label="Choose a theme">
{{#each themes as theme}} {{#each themes as theme}}
<div class="theme-group"> <div class="theme-group">
<input type="radio" id="choice-theme-{{theme.name}}" <input type="radio" id="choice-theme-{{theme.name}}"
@ -27,7 +27,7 @@
{{/each}} {{/each}}
</form> </form>
<form class="instance-actions"> <form class="instance-actions" aria-label="Switch to or log out of this instance">
{{#if $loggedInInstancesInOrder.length > 1}} {{#if $loggedInInstancesInOrder.length > 1}}
<button class="primary" disabled="{{$currentInstance === params.instanceName}}" <button class="primary" disabled="{{$currentInstance === params.instanceName}}"
on:click="onSwitchToThisInstance()"> on:click="onSwitchToThisInstance()">

View file

@ -4,7 +4,7 @@
<Layout page='settings'> <Layout page='settings'>
<SettingsLayout page='settings/instances/add' label="Add an Instance"> <SettingsLayout page='settings/instances/add' label="Add an Instance">
<h1>Add an Instance</h1> <h1 id="add-an-instance-h1">Add an Instance</h1>
<LoadingMask show="{{loading}}"/> <LoadingMask show="{{loading}}"/>
@ -14,7 +14,7 @@
<p>Log in to an instance to start using Pinafore.</p> <p>Log in to an instance to start using Pinafore.</p>
{{/if}} {{/if}}
<form class="add-new-instance" on:submit='onSubmit(event)'> <form class="add-new-instance" on:submit='onSubmit(event)' aria-labelledby="add-an-instance-h1">
<label for="instanceInput">Instance:</label> <label for="instanceInput">Instance:</label>
<input type="text" id="instanceInput" bind:value='$instanceNameInSearch' placeholder=''> <input type="text" id="instanceInput" bind:value='$instanceNameInSearch' placeholder=''>
<button class="primary" type="submit" id="submitButton" disabled="{{!$instanceNameInSearch}}">Add instance</button> <button class="primary" type="submit" id="submitButton" disabled="{{!$instanceNameInSearch}}">Add instance</button>

View file

@ -8,12 +8,6 @@
<link rel='manifest' href='/manifest.json'> <link rel='manifest' href='/manifest.json'>
<link rel='icon' type='image/png' href='/favicon.png'> <link rel='icon' type='image/png' href='/favicon.png'>
<script>
if (!location.origin.match('localhost') && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js');
}
</script>
<style> <style>
/* auto-generated w/ build-sass.js */ /* auto-generated w/ build-sass.js */
body{--button-primary-bg:#6081e6;--button-primary-text:#fff;--button-primary-border:#132c76;--button-primary-bg-active:#456ce2;--button-primary-bg-hover:#6988e7;--button-bg:#e6e6e6;--button-text:#333;--button-border:#a7a7a7;--button-bg-active:#bfbfbf;--button-bg-hover:#f2f2f2;--input-border:#dadada;--anchor-text:#4169e1;--main-bg:#fff;--body-bg:#e8edfb;--body-text-color:#333;--main-border:#dadada;--svg-fill:#4169e1;--form-bg:#f7f7f7;--form-border:#c1c1c1;--nav-bg:#4169e1;--nav-border:#214cce;--nav-a-border:#4169e1;--nav-a-selected-border:#fff;--nav-a-selected-bg:#6d8ce8;--nav-svg-fill:#fff;--nav-text-color:#fff;--nav-a-selected-border-hover:#fff;--nav-a-selected-bg-hover:#839deb;--nav-a-bg-hover:#577ae4;--nav-a-border-hover:#4169e1;--nav-svg-fill-hover:#fff;--nav-text-color-hover:#fff;--action-button-fill-color:#839deb;--action-button-fill-color-hover:#99afef;--action-button-fill-color-active:#577ae4;--settings-list-item-bg:#fff;--settings-list-item-text:#4169e1;--settings-list-item-text-hover:#4169e1;--settings-list-item-border:#dadada;--settings-list-item-bg-active:#e6e6e6;--settings-list-item-bg-hover:#fafafa;--toast-bg:#333;--toast-border:#fafafa;--toast-text:#fff;--mask-bg:#333;--mask-svg-fill:#fff;--deemphasized-text-color:#666} body{--button-primary-bg:#6081e6;--button-primary-text:#fff;--button-primary-border:#132c76;--button-primary-bg-active:#456ce2;--button-primary-bg-hover:#6988e7;--button-bg:#e6e6e6;--button-text:#333;--button-border:#a7a7a7;--button-bg-active:#bfbfbf;--button-bg-hover:#f2f2f2;--input-border:#dadada;--anchor-text:#4169e1;--main-bg:#fff;--body-bg:#e8edfb;--body-text-color:#333;--main-border:#dadada;--svg-fill:#4169e1;--form-bg:#f7f7f7;--form-border:#c1c1c1;--nav-bg:#4169e1;--nav-border:#214cce;--nav-a-border:#4169e1;--nav-a-selected-border:#fff;--nav-a-selected-bg:#6d8ce8;--nav-svg-fill:#fff;--nav-text-color:#fff;--nav-a-selected-border-hover:#fff;--nav-a-selected-bg-hover:#839deb;--nav-a-bg-hover:#577ae4;--nav-a-border-hover:#4169e1;--nav-svg-fill-hover:#fff;--nav-text-color-hover:#fff;--action-button-fill-color:#839deb;--action-button-fill-color-hover:#99afef;--action-button-fill-color-active:#577ae4;--settings-list-item-bg:#fff;--settings-list-item-text:#4169e1;--settings-list-item-text-hover:#4169e1;--settings-list-item-border:#dadada;--settings-list-item-bg-active:#e6e6e6;--settings-list-item-bg-hover:#fafafa;--toast-bg:#333;--toast-border:#fafafa;--toast-text:#fff;--mask-bg:#333;--mask-svg-fill:#fff;--deemphasized-text-color:#666}

View file

@ -1,12 +1,12 @@
import { init } from 'sapper/runtime.js' import { init } from 'sapper/runtime.js'
import { toast } from '../routes/_utils/toast' import { offlineNotifiction } from '../routes/_utils/offlineNotification'
import { serviceWorkerClient } from '../routes/_utils/serviceWorkerClient'
import { import {
importURLSearchParams, importURLSearchParams,
importIntersectionObserver, importIntersectionObserver,
importRequestIdleCallback, importRequestIdleCallback,
importIndexedDBGetAllShim, importIndexedDBGetAllShim
importOfflineNotification
} from '../routes/_utils/asyncModules' } from '../routes/_utils/asyncModules'
// polyfills // polyfills
@ -18,14 +18,4 @@ Promise.all([
]).then(() => { ]).then(() => {
// `routes` is an array of route objects injected by Sapper // `routes` is an array of route objects injected by Sapper
init(document.querySelector('#sapper'), __routes__) init(document.querySelector('#sapper'), __routes__)
})
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.onstatechange = (e) => {
if (e.target.state === 'redundant') {
toast.say('App update available. Reload to update.');
}
}
}
})
importOfflineNotification()