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": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz",

View file

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

View file

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

View file

@ -11,16 +11,18 @@
</style>
<script>
import { store } from '../_utils/store'
import { getHomeTimeline } from '../_utils/mastodon/timelines'
import { getTimeline } from '../_utils/mastodon/timelines'
import StatusListItem from './StatusListItem.html'
import VirtualList from './VirtualList.html'
import { splice, push } from 'svelte-extras'
import {
insertStatuses as insertStatusesIntoDatabase,
getTimelineAfter as getTimelineFromDatabaseAfter
getTimeline as getTimelineFromDatabase
} from '../_utils/database/statuses'
import { mergeStatuses } from '../_utils/statuses'
import { mark, stop } from '../_utils/marks'
import { timelines } from '../_static/timelines'
const FETCH_LIMIT = 20
@ -30,15 +32,14 @@
let instanceData = this.store.get('currentInstanceData')
let online = this.get('online')
let statuses = online ?
await getHomeTimeline(instanceName, instanceData.access_token, null, FETCH_LIMIT) :
await getTimelineFromDatabaseAfter(null, FETCH_LIMIT)
await getTimeline(instanceName, instanceData.access_token, this.get('timeline'), null, FETCH_LIMIT) :
await getTimelineFromDatabase(instanceName, this.get('timeline'), null, FETCH_LIMIT)
if (online) {
insertStatusesIntoDatabase(statuses)
insertStatusesIntoDatabase(instanceName, this.get('timeline'), statuses)
}
this.addStatuses(statuses)
},
data: () => ({
target: 'home',
StatusListItem: StatusListItem,
statuses: [],
runningUpdate: false
@ -49,7 +50,7 @@
key: status.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,
components: {
@ -69,10 +70,10 @@
let instanceData = this.store.get('currentInstanceData')
let online = this.get('online')
let newStatuses = online ?
await getHomeTimeline(instanceName, instanceData.access_token, lastStatusId, FETCH_LIMIT) :
await getTimelineFromDatabaseAfter(lastStatusId, FETCH_LIMIT)
await getTimeline(instanceName, instanceData.access_token, this.get('timeline'), lastStatusId, FETCH_LIMIT) :
await getTimelineFromDatabase(instanceName, this.get('timeline'), lastStatusId, FETCH_LIMIT)
if (online) {
insertStatusesIntoDatabase(newStatuses)
insertStatusesIntoDatabase(instanceName, this.get('timeline'), newStatuses)
}
let statuses = this.get('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'
)
const importOfflineNotification = () => import(
/* webpackHunkName: 'offlineNotification' */ '../_utils/offlineNotification'
)
export {
importURLSearchParams,
importTimeline,
importIntersectionObserver,
importRequestIdleCallback,
importIndexedDBGetAllShim,
importOfflineNotification
importIndexedDBGetAllShim
}

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 padStart from 'lodash/padStart'
const STORE = 'statuses'
const databaseCache = {}
function toPaddedBigInt(id) {
return padStart(id, 30, '0')
@ -24,8 +26,22 @@ function transformStatusForStorage(status) {
return status
}
const dbPromise = new Promise((resolve, reject) => {
let req = indexedDB.open(STORE, 1)
function getDatabase(instanceName, timeline) {
const key = `${instanceName}_${timeline}`
if (databaseCache[key]) {
return Promise.resolve(databaseCache[key])
}
let objectStoreName = `${STORE}_${key}`
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')
@ -35,15 +51,15 @@ const dbPromise = new Promise((resolve, reject) => {
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)
})
})
return databaseCache[key]
}
export async function getTimelineAfter(max_id = null, limit = 20) {
const db = await dbPromise
export async function getTimeline(instanceName, timeline, max_id = null, limit = 20) {
const db = await getDatabase(instanceName, timeline)
return await new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly')
const store = tx.objectStore(STORE)
@ -61,8 +77,8 @@ export async function getTimelineAfter(max_id = null, limit = 20) {
})
}
export async function insertStatuses(statuses) {
const db = await dbPromise
export async function insertStatuses(instanceName, timeline, statuses) {
const db = await getDatabase(instanceName, timeline)
return await new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite')
const store = tx.objectStore(STORE)

View file

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

View file

@ -17,5 +17,9 @@ const observe = online => {
}
}
if (!navigator.onLine) {
observe(false)
}
window.addEventListener('offline', () => observe(false));
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>
<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>
<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 {
store: () => store,
components: {
Layout
Layout,
LazyTimeline,
FreeTextLayout
}
}
};
</script>

View file

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

View file

@ -3,16 +3,29 @@
</:Head>
<Layout page='local'>
{{#if $isUserLoggedIn}}
<LazyTimeline timeline='local' />
{{else}}
<FreeTextLayout>
<h1>Local</h1>
<p>Your local timeline will appear here when logged in.</p>
</FreeTextLayout>
{{/if}}
</Layout>
<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 {
store: () => store,
components: {
Layout
Layout,
LazyTimeline,
FreeTextLayout
}
}
};
</script>

View file

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

View file

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

View file

@ -4,7 +4,7 @@
<Layout page='settings'>
<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}}"/>
@ -14,7 +14,7 @@
<p>Log in to an instance to start using Pinafore.</p>
{{/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>
<input type="text" id="instanceInput" bind:value='$instanceNameInSearch' placeholder=''>
<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='icon' type='image/png' href='/favicon.png'>
<script>
if (!location.origin.match('localhost') && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js');
}
</script>
<style>
/* 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}

View file

@ -1,12 +1,12 @@
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 {
importURLSearchParams,
importIntersectionObserver,
importRequestIdleCallback,
importIndexedDBGetAllShim,
importOfflineNotification
importIndexedDBGetAllShim
} from '../routes/_utils/asyncModules'
// polyfills
@ -18,14 +18,4 @@ Promise.all([
]).then(() => {
// `routes` is an array of route objects injected by Sapper
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()