add ability to fetch and store notifications

This commit is contained in:
Nolan Lawson 2018-02-03 18:06:02 -08:00
parent 43170b9f6f
commit c8cb4354e3
11 changed files with 166 additions and 98 deletions

View File

@ -2,55 +2,51 @@ import { store } from '../_store/store'
import { database } from '../_utils/database/database' import { database } from '../_utils/database/database'
import { getTimeline } from '../_utils/mastodon/timelines' import { getTimeline } from '../_utils/mastodon/timelines'
import { toast } from '../_utils/toast' import { toast } from '../_utils/toast'
import { StatusStream } from '../_utils/mastodon/StatusStream'
import { getInstanceInfo } from '../_utils/mastodon/instance'
import { mark, stop } from '../_utils/marks' import { mark, stop } from '../_utils/marks'
import { mergeArrays } from '../_utils/arrays' import { mergeArrays } from '../_utils/arrays'
const FETCH_LIMIT = 20 const FETCH_LIMIT = 20
let statusStream async function fetchTimelineItems(instanceName, accessToken, timelineName, lastTimelineItemId, online) {
mark('fetchTimelineItems')
async function fetchStatuses(instanceName, accessToken, timelineName, lastStatusId, online) { let items
mark('fetchStatuses')
let statuses
if (!online) { if (!online) {
statuses = await database.getTimeline(instanceName, timelineName, lastStatusId, FETCH_LIMIT) items = await database.getTimeline(instanceName, timelineName, lastTimelineItemId, FETCH_LIMIT)
} else { } else {
try { try {
statuses = await getTimeline(instanceName, accessToken, timelineName, lastStatusId, FETCH_LIMIT) items = await getTimeline(instanceName, accessToken, timelineName, lastTimelineItemId, FETCH_LIMIT)
/* no await */ database.insertStatuses(instanceName, timelineName, statuses) /* no await */ database.insertTimelineItems(instanceName, timelineName, items)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say('Internet request failed. Showing offline content.') toast.say('Internet request failed. Showing offline content.')
statuses = await database.getTimeline(instanceName, timelineName, lastStatusId, FETCH_LIMIT) items = await database.getTimeline(instanceName, timelineName, lastTimelineItemId, FETCH_LIMIT)
} }
} }
stop('fetchStatuses') stop('fetchTimelineItems')
return statuses return items
} }
async function addStatuses(instanceName, timelineName, newStatuses) { async function addTimelineItems(instanceName, timelineName, newItems) {
console.log('addStatuses, length:', newStatuses.length) console.log('addTimelineItems, length:', newItems.length)
mark('addStatuses') mark('addTimelineItems')
let newStatusIds = newStatuses.map(status => status.id) let newIds = newItems.map(item => item.id)
let oldStatusIds = store.getForTimeline(instanceName, timelineName, 'statusIds') || [] let oldIds = store.getForTimeline(instanceName, timelineName, 'timelineItemIds') || []
let merged = mergeArrays(oldStatusIds, newStatusIds) let merged = mergeArrays(oldIds, newIds)
store.setForTimeline(instanceName, timelineName, { statusIds: merged }) store.setForTimeline(instanceName, timelineName, { timelineItemIds: merged })
stop('addStatuses') stop('addTimelineItems')
} }
async function fetchStatusesAndPossiblyFallBack() { async function fetchTimelineItemsAndPossiblyFallBack() {
mark('fetchStatusesAndPossiblyFallBack') mark('fetchTimelineItemsAndPossiblyFallBack')
let timelineName = store.get('currentTimeline') let timelineName = store.get('currentTimeline')
let instanceName = store.get('currentInstance') let instanceName = store.get('currentInstance')
let accessToken = store.get('accessToken') let accessToken = store.get('accessToken')
let lastStatusId = store.get('lastStatusId') let lastTimelineItemId = store.get('lastTimelineItemId')
let online = store.get('online') let online = store.get('online')
let newStatuses = await fetchStatuses(instanceName, accessToken, timelineName, lastStatusId, online) let newItems = await fetchTimelineItems(instanceName, accessToken, timelineName, lastTimelineItemId, online)
addStatuses(instanceName, timelineName, newStatuses) addTimelineItems(instanceName, timelineName, newItems)
stop('fetchStatusesAndPossiblyFallBack') stop('fetchTimelineItemsAndPossiblyFallBack')
} }
export function initializeTimeline() { export function initializeTimeline() {
@ -66,30 +62,20 @@ export function initializeTimeline() {
} }
export async function setupTimeline() { export async function setupTimeline() {
mark('addStatuses') mark('setupTimeline')
let timelineName = store.get('currentTimeline') let timelineName = store.get('currentTimeline')
let instanceName = store.get('currentInstance') let instanceName = store.get('currentInstance')
let accessToken = store.get('accessToken') let accessToken = store.get('accessToken')
if (!store.get('statusIds').length) { if (!store.get('timelineItemIds').length) {
await fetchStatusesAndPossiblyFallBack() await fetchTimelineItemsAndPossiblyFallBack()
} }
/* no await */ getInstanceInfo(instanceName).then(instanceInfo => database.setInstanceInfo(instanceName, instanceInfo)) stop('setupTimeline')
let instanceInfo = await database.getInstanceInfo(instanceName)
if (statusStream) {
statusStream.close()
}
/*statusStream = new StatusStream(instanceInfo.urls.streaming_api, accessToken, timelineName, {
onMessage(message) {
console.log('message', message)
}
})*/
stop('addStatuses')
} }
export async function fetchStatusesOnScrollToBottom() { export async function fetchTimelineItemsOnScrollToBottom() {
let timelineName = store.get('currentTimeline') let timelineName = store.get('currentTimeline')
let instanceName = store.get('currentInstance') let instanceName = store.get('currentInstance')
store.setForTimeline(instanceName, timelineName, { runningUpdate: true }) store.setForTimeline(instanceName, timelineName, { runningUpdate: true })
await fetchStatusesAndPossiblyFallBack() await fetchTimelineItemsAndPossiblyFallBack()
store.setForTimeline(instanceName, timelineName, { runningUpdate: false }) store.setForTimeline(instanceName, timelineName, { runningUpdate: false })
} }

View File

@ -0,0 +1,7 @@
<article class="notification-article"
tabindex="0"
aria-posinset="{{index}}" aria-setsize="{{length}}"
on:recalculateHeight
>
Notification
</article>

View File

@ -0,0 +1,16 @@
<Notification
notification="{{virtualProps.notification}}"
timelineType="{{virtualProps.timelineType}}"
timelineValue="{{virtualProps.timelineValue}}"
index="{{virtualIndex}}"
length="{{virtualLength}}"
on:recalculateHeight />
<script>
import Notification from '../notification/Notification.html'
export default {
components: {
Notification
}
}
</script>

View File

@ -4,10 +4,21 @@
<LoadingSpinner /> <LoadingSpinner />
</div> </div>
{{/if}} {{/if}}
{{#if virtual}} {{#if timelineType === 'notifications'}}
<VirtualList component="{{NotificationVirtualListItem}}"
:makeProps
items="{{$timelineItemIds}}"
on:scrollToBottom="onScrollToBottom()"
shown="{{$initialized}}"
footerComponent="{{LoadingFooter}}"
showFooter="{{$initialized && $runningUpdate}}"
realm="{{$currentInstance + '/' + timeline}}"
on:initializedVisibleItems="initialize()"
/>
{{elseif virtual}}
<VirtualList component="{{StatusVirtualListItem}}" <VirtualList component="{{StatusVirtualListItem}}"
:makeProps :makeProps
items="{{$statusIds}}" items="{{$timelineItemIds}}"
on:scrollToBottom="onScrollToBottom()" on:scrollToBottom="onScrollToBottom()"
shown="{{$initialized}}" shown="{{$initialized}}"
footerComponent="{{LoadingFooter}}" footerComponent="{{LoadingFooter}}"
@ -20,7 +31,7 @@
whole thing rather than use a virtual list --> whole thing rather than use a virtual list -->
<PseudoVirtualList component="{{StatusVirtualListItem}}" <PseudoVirtualList component="{{StatusVirtualListItem}}"
:makeProps :makeProps
items="{{$statusIds}}" items="{{$timelineItemIds}}"
shown="{{$initialized}}" shown="{{$initialized}}"
on:initializedVisibleItems="initialize()" on:initializedVisibleItems="initialize()"
scrollToItem="{{timelineValue}}" scrollToItem="{{timelineValue}}"
@ -48,13 +59,14 @@
<script> <script>
import { store } from '../../_store/store' import { store } from '../../_store/store'
import StatusVirtualListItem from './StatusVirtualListItem.html' import StatusVirtualListItem from './StatusVirtualListItem.html'
import NotificationVirtualListItem from './NotificationVirtualListItem.html'
import Status from '../status/Status.html' import Status from '../status/Status.html'
import PseudoVirtualList from '../pseudoVirtualList/PseudoVirtualList.html' import PseudoVirtualList from '../pseudoVirtualList/PseudoVirtualList.html'
import LoadingFooter from './LoadingFooter.html' import LoadingFooter from './LoadingFooter.html'
import VirtualList from '../virtualList/VirtualList.html' import VirtualList from '../virtualList/VirtualList.html'
import { timelines } from '../../_static/timelines' import { timelines } from '../../_static/timelines'
import { database } from '../../_utils/database/database' import { database } from '../../_utils/database/database'
import { initializeTimeline, fetchStatusesOnScrollToBottom, setupTimeline } from '../../_actions/timeline' import { initializeTimeline, fetchTimelineItemsOnScrollToBottom, setupTimeline } from '../../_actions/timeline'
import LoadingSpinner from '../LoadingSpinner.html' import LoadingSpinner from '../LoadingSpinner.html'
export default { export default {
@ -64,15 +76,20 @@
}, },
data: () => ({ data: () => ({
StatusVirtualListItem, StatusVirtualListItem,
NotificationVirtualListItem,
LoadingFooter, LoadingFooter,
Status Status
}), }),
computed: { computed: {
makeProps: ($currentInstance, timelineType, timelineValue) => async (statusId) => ({ makeProps: ($currentInstance, timelineType, timelineValue) => async (itemId) => {
timelineType: timelineType, let res = { timelineType, timelineValue }
timelineValue: timelineValue, if (timelineType === 'notifications') {
status: await database.getStatus($currentInstance, statusId) res.notification = await database.getNotification($currentInstance, itemId)
}), } else {
res.status = await database.getStatus($currentInstance, itemId)
}
return res
},
label: (timeline, $currentInstance, timelineType, timelineValue) => { label: (timeline, $currentInstance, timelineType, timelineValue) => {
if (timelines[timeline]) { if (timelines[timeline]) {
return `${timelines[timeline].label} timeline for ${$currentInstance}` return `${timelines[timeline].label} timeline for ${$currentInstance}`
@ -101,11 +118,12 @@
components: { components: {
VirtualList, VirtualList,
PseudoVirtualList, PseudoVirtualList,
NotificationVirtualListItem,
LoadingSpinner LoadingSpinner
}, },
methods: { methods: {
initialize() { initialize() {
if (this.store.get('initialized') || !this.store.get('statusIds') || !this.store.get('statusIds').length) { if (this.store.get('initialized') || !this.store.get('timelineItemIds') || !this.store.get('timelineItemIds').length) {
return return
} }
console.log('timeline initialize()') console.log('timeline initialize()')
@ -117,7 +135,7 @@
this.get('timelineType') === 'status') { // for status contexts, we've already fetched the whole thread this.get('timelineType') === 'status') { // for status contexts, we've already fetched the whole thread
return return
} }
fetchStatusesOnScrollToBottom() fetchTimelineItemsOnScrollToBottom()
} }
} }
} }

View File

@ -4,8 +4,8 @@ export function timelineComputations(store) {
return ((timelines && timelines[currentInstance]) || {})[currentTimeline] || {} return ((timelines && timelines[currentInstance]) || {})[currentTimeline] || {}
}) })
store.compute('statusIds', ['currentTimelineData'], (currentTimelineData) => currentTimelineData.statusIds || []) store.compute('timelineItemIds', ['currentTimelineData'], (currentTimelineData) => currentTimelineData.timelineItemIds || [])
store.compute('runningUpdate', ['currentTimelineData'], (currentTimelineData) => currentTimelineData.runningUpdate) store.compute('runningUpdate', ['currentTimelineData'], (currentTimelineData) => currentTimelineData.runningUpdate)
store.compute('initialized', ['currentTimelineData'], (currentTimelineData) => currentTimelineData.initialized) store.compute('initialized', ['currentTimelineData'], (currentTimelineData) => currentTimelineData.initialized)
store.compute('lastStatusId', ['statusIds'], (statusIds) => statusIds.length && statusIds[statusIds.length - 1]) store.compute('lastTimelineItemId', ['timelineItemIds'], (timelineItemIds) => timelineItemIds.length && timelineItemIds[timelineItemIds.length - 1])
} }

View File

@ -16,13 +16,18 @@ export const metaCache = {
maxSize: 20, maxSize: 20,
caches: {} caches: {}
} }
export const notificationsCache = {
maxSize: 50,
caches: {}
}
if (process.browser && process.env.NODE_ENV !== 'production') { if (process.browser && process.env.NODE_ENV !== 'production') {
window.cacheStats = { window.cacheStats = {
statuses: statusesCache, statuses: statusesCache,
accounts: accountsCache, accounts: accountsCache,
relationships: relationshipsCache, relationships: relationshipsCache,
meta: metaCache meta: metaCache,
notifications: notificationsCache
} }
} }

View File

@ -1,5 +1,7 @@
export const STATUSES_STORE = 'statuses' export const STATUSES_STORE = 'statuses'
export const TIMELINE_STORE = 'timelines' export const STATUS_TIMELINES_STORE = 'status_timelines'
export const META_STORE = 'meta' export const META_STORE = 'meta'
export const ACCOUNTS_STORE = 'accounts' export const ACCOUNTS_STORE = 'accounts'
export const RELATIONSHIPS_STORE = 'relationships' export const RELATIONSHIPS_STORE = 'relationships'
export const NOTIFICATIONS_STORE = 'notifications'
export const NOTIFICATION_TIMELINES_STORE = 'notification_timelines'

View File

@ -9,10 +9,11 @@ import {
import { import {
META_STORE, META_STORE,
TIMELINE_STORE, STATUS_TIMELINES_STORE,
STATUSES_STORE, STATUSES_STORE,
ACCOUNTS_STORE, ACCOUNTS_STORE,
RELATIONSHIPS_STORE RELATIONSHIPS_STORE,
NOTIFICATIONS_STORE, NOTIFICATION_TIMELINES_STORE
} from './constants' } from './constants'
import { import {
@ -20,6 +21,7 @@ import {
relationshipsCache, relationshipsCache,
accountsCache, accountsCache,
metaCache, metaCache,
notificationsCache,
clearCache, clearCache,
getInCache, getInCache,
hasInCache, hasInCache,
@ -51,13 +53,29 @@ async function setGenericEntityWithId(store, cache, instanceName, entity) {
} }
// //
// timelines/statuses // timelines/statuses/notifications
// //
function getTimelineVariables(timeline) {
if (timeline === 'notifications') {
return {
stores: [NOTIFICATION_TIMELINES_STORE, NOTIFICATIONS_STORE, ACCOUNTS_STORE],
remoteId: 'notificationId',
itemsCache: notificationsCache
}
}
return {
stores: [STATUS_TIMELINES_STORE, STATUSES_STORE, ACCOUNTS_STORE],
remoteId: 'statusId',
itemsCache: statusesCache
}
}
export async function getTimeline(instanceName, timeline, maxId = null, limit = 20) { export async function getTimeline(instanceName, timeline, maxId = null, limit = 20) {
const db = await getDatabase(instanceName, timeline) let { stores, remoteId } = getTimelineVariables(timeline)
return await dbPromise(db, [TIMELINE_STORE, STATUSES_STORE], 'readonly', (stores, callback) => { const db = await getDatabase(instanceName)
let [ timelineStore, statusesStore ] = stores return await dbPromise(db, stores, 'readonly', (stores, callback) => {
let [ timelineStore, itemsStore ] = stores
let negBigInt = maxId && toReversePaddedBigInt(maxId) let negBigInt = maxId && toReversePaddedBigInt(maxId)
let start = negBigInt ? (timeline + '\u0000' + negBigInt) : (timeline + '\u0000') let start = negBigInt ? (timeline + '\u0000' + negBigInt) : (timeline + '\u0000')
@ -68,7 +86,7 @@ export async function getTimeline(instanceName, timeline, maxId = null, limit =
let timelineResults = e.target.result let timelineResults = e.target.result
let res = new Array(timelineResults.length) let res = new Array(timelineResults.length)
timelineResults.forEach((timelineResult, i) => { timelineResults.forEach((timelineResult, i) => {
statusesStore.get(timelineResult.statusId).onsuccess = e => { itemsStore.get(timelineResult[remoteId]).onsuccess = e => {
res[i] = e.target.result res[i] = e.target.result
} }
}) })
@ -77,27 +95,28 @@ export async function getTimeline(instanceName, timeline, maxId = null, limit =
}) })
} }
export async function insertStatuses(instanceName, timeline, statuses) { export async function insertTimelineItems(instanceName, timeline, timelineItems) {
for (let status of statuses) { let { stores, remoteId, itemsCache } = getTimelineVariables(timeline)
setInCache(statusesCache, instanceName, status.id, status) for (let timelineItem of timelineItems) {
setInCache(accountsCache, instanceName, status.account.id, status.account) setInCache(itemsCache, instanceName, timelineItem.id, timelineItem)
if (status.reblog) { setInCache(accountsCache, instanceName, timelineItem.account.id, timelineItem.account)
setInCache(accountsCache, instanceName, status.reblog.account.id, status.reblog.account) if (timelineItem.reblog) {
setInCache(accountsCache, instanceName, timelineItem.reblog.account.id, timelineItem.reblog.account)
} }
} }
const db = await getDatabase(instanceName, timeline) const db = await getDatabase(instanceName)
await dbPromise(db, [TIMELINE_STORE, STATUSES_STORE, ACCOUNTS_STORE], 'readwrite', (stores) => { await dbPromise(db, stores, 'readwrite', (stores) => {
let [ timelineStore, statusesStore, accountsStore ] = stores let [ timelineStore, itemsStore, accountsStore ] = stores
for (let status of statuses) { for (let item of timelineItems) {
statusesStore.put(status) itemsStore.put(item)
// reverse chronological order, prefixed by timeline // reverse chronological order, prefixed by timeline
timelineStore.put({ timelineStore.put({
id: (timeline + '\u0000' + toReversePaddedBigInt(status.id)), id: (timeline + '\u0000' + toReversePaddedBigInt(item.id)),
statusId: status.id [remoteId]: item.id
}) })
accountsStore.put(status.account) accountsStore.put(item.account)
if (status.reblog) { if (item.reblog) {
accountsStore.put(status.reblog.account) accountsStore.put(item.reblog.account)
} }
} }
}) })
@ -107,6 +126,10 @@ export async function getStatus(instanceName, statusId) {
return await getGenericEntityWithId(STATUSES_STORE, statusesCache, instanceName, statusId) return await getGenericEntityWithId(STATUSES_STORE, statusesCache, instanceName, statusId)
} }
export async function getNotification(instanceName, notificationId) {
return await getGenericEntityWithId(NOTIFICATIONS_STORE, notificationsCache, instanceName, notificationId)
}
// //
// meta // meta
// //

View File

@ -1,14 +1,16 @@
const openReqs = {} const openReqs = {}
const databaseCache = {} const databaseCache = {}
const DB_VERSION = 2 const DB_VERSION = 1
import { import {
META_STORE, META_STORE,
TIMELINE_STORE, STATUS_TIMELINES_STORE,
STATUSES_STORE, STATUSES_STORE,
ACCOUNTS_STORE, ACCOUNTS_STORE,
RELATIONSHIPS_STORE RELATIONSHIPS_STORE,
NOTIFICATIONS_STORE,
NOTIFICATION_TIMELINES_STORE
} from './constants' } from './constants'
export function getDatabase(instanceName) { export function getDatabase(instanceName) {
@ -28,16 +30,15 @@ export function getDatabase(instanceName) {
} }
req.onupgradeneeded = (e) => { req.onupgradeneeded = (e) => {
let db = req.result; let db = req.result;
if (e.oldVersion < 1) {
db.createObjectStore(META_STORE, {keyPath: 'key'}) db.createObjectStore(META_STORE, {keyPath: 'key'})
db.createObjectStore(STATUSES_STORE, {keyPath: 'id'}) db.createObjectStore(STATUSES_STORE, {keyPath: 'id'})
db.createObjectStore(ACCOUNTS_STORE, {keyPath: 'id'}) db.createObjectStore(ACCOUNTS_STORE, {keyPath: 'id'})
let timelineStore = db.createObjectStore(TIMELINE_STORE, {keyPath: 'id'})
timelineStore.createIndex('statusId', 'statusId')
}
if (e.oldVersion < 2) {
db.createObjectStore(RELATIONSHIPS_STORE, {keyPath: 'id'}) db.createObjectStore(RELATIONSHIPS_STORE, {keyPath: 'id'})
} db.createObjectStore(NOTIFICATIONS_STORE, {keyPath: 'id'})
db.createObjectStore(STATUS_TIMELINES_STORE, {keyPath: 'id'})
.createIndex('statusId', 'statusId')
db.createObjectStore(NOTIFICATION_TIMELINES_STORE, {keyPath: 'id'})
.createIndex('notificationId', 'notificationId')
} }
req.onsuccess = () => resolve(req.result) req.onsuccess = () => resolve(req.result)
}) })

View File

@ -8,6 +8,8 @@ function getTimelineUrlPath(timeline) {
return 'timelines/public' return 'timelines/public'
case 'home': case 'home':
return 'timelines/home' return 'timelines/home'
case 'notifications':
return 'notifications'
} }
if (timeline.startsWith('tag/')) { if (timeline.startsWith('tag/')) {
return 'timelines/tag' return 'timelines/tag'

View File

@ -2,7 +2,10 @@
<title>Pinafore Notifications</title> <title>Pinafore Notifications</title>
</:Head> </:Head>
<Layout page='notifications' virtual="true" virtualRealm="federated"> <Layout page='notifications' virtual="true" virtualRealm="notifications">
{{#if $isUserLoggedIn}}
<LazyTimeline timeline='notifications' />
{{else}}
<HiddenFromSSR> <HiddenFromSSR>
<FreeTextLayout> <FreeTextLayout>
<h1>Notifications</h1> <h1>Notifications</h1>
@ -10,18 +13,23 @@
<p>Your notifications will appear here when logged in.</p> <p>Your notifications will appear here when logged in.</p>
</FreeTextLayout> </FreeTextLayout>
</HiddenFromSSR> </HiddenFromSSR>
{{/if}}
</Layout> </Layout>
<script> <script>
import Layout from './_components/Layout.html'; import Layout from './_components/Layout.html'
import LazyTimeline from './_components/timeline/LazyTimeline.html'
import FreeTextLayout from './_components/FreeTextLayout.html' import FreeTextLayout from './_components/FreeTextLayout.html'
import { store } from './_store/store.js'
import HiddenFromSSR from './_components/HiddenFromSSR' import HiddenFromSSR from './_components/HiddenFromSSR'
export default { export default {
store: () => store,
components: { components: {
Layout, Layout,
LazyTimeline,
FreeTextLayout, FreeTextLayout,
HiddenFromSSR HiddenFromSSR
}, }
}; };
</script> </script>