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 { getTimeline } from '../_utils/mastodon/timelines'
import { toast } from '../_utils/toast'
import { StatusStream } from '../_utils/mastodon/StatusStream'
import { getInstanceInfo } from '../_utils/mastodon/instance'
import { mark, stop } from '../_utils/marks'
import { mergeArrays } from '../_utils/arrays'
const FETCH_LIMIT = 20
let statusStream
async function fetchStatuses(instanceName, accessToken, timelineName, lastStatusId, online) {
mark('fetchStatuses')
let statuses
async function fetchTimelineItems(instanceName, accessToken, timelineName, lastTimelineItemId, online) {
mark('fetchTimelineItems')
let items
if (!online) {
statuses = await database.getTimeline(instanceName, timelineName, lastStatusId, FETCH_LIMIT)
items = await database.getTimeline(instanceName, timelineName, lastTimelineItemId, FETCH_LIMIT)
} else {
try {
statuses = await getTimeline(instanceName, accessToken, timelineName, lastStatusId, FETCH_LIMIT)
/* no await */ database.insertStatuses(instanceName, timelineName, statuses)
items = await getTimeline(instanceName, accessToken, timelineName, lastTimelineItemId, FETCH_LIMIT)
/* no await */ database.insertTimelineItems(instanceName, timelineName, items)
} catch (e) {
console.error(e)
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')
return statuses
stop('fetchTimelineItems')
return items
}
async function addStatuses(instanceName, timelineName, newStatuses) {
console.log('addStatuses, length:', newStatuses.length)
mark('addStatuses')
let newStatusIds = newStatuses.map(status => status.id)
let oldStatusIds = store.getForTimeline(instanceName, timelineName, 'statusIds') || []
let merged = mergeArrays(oldStatusIds, newStatusIds)
store.setForTimeline(instanceName, timelineName, { statusIds: merged })
stop('addStatuses')
async function addTimelineItems(instanceName, timelineName, newItems) {
console.log('addTimelineItems, length:', newItems.length)
mark('addTimelineItems')
let newIds = newItems.map(item => item.id)
let oldIds = store.getForTimeline(instanceName, timelineName, 'timelineItemIds') || []
let merged = mergeArrays(oldIds, newIds)
store.setForTimeline(instanceName, timelineName, { timelineItemIds: merged })
stop('addTimelineItems')
}
async function fetchStatusesAndPossiblyFallBack() {
mark('fetchStatusesAndPossiblyFallBack')
async function fetchTimelineItemsAndPossiblyFallBack() {
mark('fetchTimelineItemsAndPossiblyFallBack')
let timelineName = store.get('currentTimeline')
let instanceName = store.get('currentInstance')
let accessToken = store.get('accessToken')
let lastStatusId = store.get('lastStatusId')
let lastTimelineItemId = store.get('lastTimelineItemId')
let online = store.get('online')
let newStatuses = await fetchStatuses(instanceName, accessToken, timelineName, lastStatusId, online)
addStatuses(instanceName, timelineName, newStatuses)
stop('fetchStatusesAndPossiblyFallBack')
let newItems = await fetchTimelineItems(instanceName, accessToken, timelineName, lastTimelineItemId, online)
addTimelineItems(instanceName, timelineName, newItems)
stop('fetchTimelineItemsAndPossiblyFallBack')
}
export function initializeTimeline() {
@ -66,30 +62,20 @@ export function initializeTimeline() {
}
export async function setupTimeline() {
mark('addStatuses')
mark('setupTimeline')
let timelineName = store.get('currentTimeline')
let instanceName = store.get('currentInstance')
let accessToken = store.get('accessToken')
if (!store.get('statusIds').length) {
await fetchStatusesAndPossiblyFallBack()
if (!store.get('timelineItemIds').length) {
await fetchTimelineItemsAndPossiblyFallBack()
}
/* no await */ getInstanceInfo(instanceName).then(instanceInfo => database.setInstanceInfo(instanceName, instanceInfo))
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')
stop('setupTimeline')
}
export async function fetchStatusesOnScrollToBottom() {
export async function fetchTimelineItemsOnScrollToBottom() {
let timelineName = store.get('currentTimeline')
let instanceName = store.get('currentInstance')
store.setForTimeline(instanceName, timelineName, { runningUpdate: true })
await fetchStatusesAndPossiblyFallBack()
await fetchTimelineItemsAndPossiblyFallBack()
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 />
</div>
{{/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}}"
:makeProps
items="{{$statusIds}}"
items="{{$timelineItemIds}}"
on:scrollToBottom="onScrollToBottom()"
shown="{{$initialized}}"
footerComponent="{{LoadingFooter}}"
@ -20,7 +31,7 @@
whole thing rather than use a virtual list -->
<PseudoVirtualList component="{{StatusVirtualListItem}}"
:makeProps
items="{{$statusIds}}"
items="{{$timelineItemIds}}"
shown="{{$initialized}}"
on:initializedVisibleItems="initialize()"
scrollToItem="{{timelineValue}}"
@ -48,13 +59,14 @@
<script>
import { store } from '../../_store/store'
import StatusVirtualListItem from './StatusVirtualListItem.html'
import NotificationVirtualListItem from './NotificationVirtualListItem.html'
import Status from '../status/Status.html'
import PseudoVirtualList from '../pseudoVirtualList/PseudoVirtualList.html'
import LoadingFooter from './LoadingFooter.html'
import VirtualList from '../virtualList/VirtualList.html'
import { timelines } from '../../_static/timelines'
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'
export default {
@ -64,15 +76,20 @@
},
data: () => ({
StatusVirtualListItem,
NotificationVirtualListItem,
LoadingFooter,
Status
}),
computed: {
makeProps: ($currentInstance, timelineType, timelineValue) => async (statusId) => ({
timelineType: timelineType,
timelineValue: timelineValue,
status: await database.getStatus($currentInstance, statusId)
}),
makeProps: ($currentInstance, timelineType, timelineValue) => async (itemId) => {
let res = { timelineType, timelineValue }
if (timelineType === 'notifications') {
res.notification = await database.getNotification($currentInstance, itemId)
} else {
res.status = await database.getStatus($currentInstance, itemId)
}
return res
},
label: (timeline, $currentInstance, timelineType, timelineValue) => {
if (timelines[timeline]) {
return `${timelines[timeline].label} timeline for ${$currentInstance}`
@ -101,11 +118,12 @@
components: {
VirtualList,
PseudoVirtualList,
NotificationVirtualListItem,
LoadingSpinner
},
methods: {
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
}
console.log('timeline initialize()')
@ -117,7 +135,7 @@
this.get('timelineType') === 'status') { // for status contexts, we've already fetched the whole thread
return
}
fetchStatusesOnScrollToBottom()
fetchTimelineItemsOnScrollToBottom()
}
}
}

View file

@ -4,8 +4,8 @@ export function timelineComputations(store) {
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('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,
caches: {}
}
export const notificationsCache = {
maxSize: 50,
caches: {}
}
if (process.browser && process.env.NODE_ENV !== 'production') {
window.cacheStats = {
statuses: statusesCache,
accounts: accountsCache,
relationships: relationshipsCache,
meta: metaCache
meta: metaCache,
notifications: notificationsCache
}
}

View file

@ -1,5 +1,7 @@
export const STATUSES_STORE = 'statuses'
export const TIMELINE_STORE = 'timelines'
export const STATUS_TIMELINES_STORE = 'status_timelines'
export const META_STORE = 'meta'
export const ACCOUNTS_STORE = 'accounts'
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 {
META_STORE,
TIMELINE_STORE,
STATUS_TIMELINES_STORE,
STATUSES_STORE,
ACCOUNTS_STORE,
RELATIONSHIPS_STORE
RELATIONSHIPS_STORE,
NOTIFICATIONS_STORE, NOTIFICATION_TIMELINES_STORE
} from './constants'
import {
@ -20,6 +21,7 @@ import {
relationshipsCache,
accountsCache,
metaCache,
notificationsCache,
clearCache,
getInCache,
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) {
const db = await getDatabase(instanceName, timeline)
return await dbPromise(db, [TIMELINE_STORE, STATUSES_STORE], 'readonly', (stores, callback) => {
let [ timelineStore, statusesStore ] = stores
let { stores, remoteId } = getTimelineVariables(timeline)
const db = await getDatabase(instanceName)
return await dbPromise(db, stores, 'readonly', (stores, callback) => {
let [ timelineStore, itemsStore ] = stores
let negBigInt = maxId && toReversePaddedBigInt(maxId)
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 res = new Array(timelineResults.length)
timelineResults.forEach((timelineResult, i) => {
statusesStore.get(timelineResult.statusId).onsuccess = e => {
itemsStore.get(timelineResult[remoteId]).onsuccess = e => {
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) {
for (let status of statuses) {
setInCache(statusesCache, instanceName, status.id, status)
setInCache(accountsCache, instanceName, status.account.id, status.account)
if (status.reblog) {
setInCache(accountsCache, instanceName, status.reblog.account.id, status.reblog.account)
export async function insertTimelineItems(instanceName, timeline, timelineItems) {
let { stores, remoteId, itemsCache } = getTimelineVariables(timeline)
for (let timelineItem of timelineItems) {
setInCache(itemsCache, instanceName, timelineItem.id, timelineItem)
setInCache(accountsCache, instanceName, timelineItem.account.id, timelineItem.account)
if (timelineItem.reblog) {
setInCache(accountsCache, instanceName, timelineItem.reblog.account.id, timelineItem.reblog.account)
}
}
const db = await getDatabase(instanceName, timeline)
await dbPromise(db, [TIMELINE_STORE, STATUSES_STORE, ACCOUNTS_STORE], 'readwrite', (stores) => {
let [ timelineStore, statusesStore, accountsStore ] = stores
for (let status of statuses) {
statusesStore.put(status)
const db = await getDatabase(instanceName)
await dbPromise(db, stores, 'readwrite', (stores) => {
let [ timelineStore, itemsStore, accountsStore ] = stores
for (let item of timelineItems) {
itemsStore.put(item)
// reverse chronological order, prefixed by timeline
timelineStore.put({
id: (timeline + '\u0000' + toReversePaddedBigInt(status.id)),
statusId: status.id
id: (timeline + '\u0000' + toReversePaddedBigInt(item.id)),
[remoteId]: item.id
})
accountsStore.put(status.account)
if (status.reblog) {
accountsStore.put(status.reblog.account)
accountsStore.put(item.account)
if (item.reblog) {
accountsStore.put(item.reblog.account)
}
}
})
@ -107,6 +126,10 @@ export async function getStatus(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
//

View file

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

View file

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

View file

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