import difference from 'lodash/difference' import times from 'lodash/times' import { cloneForStorage } from './helpers' import { dbPromise, getDatabase } from './databaseLifecycle' import { accountsCache, deleteFromCache, getInCache, hasInCache, notificationsCache, setInCache, statusesCache } from './cache' import { scheduleCleanup } from './cleanup' import { ACCOUNTS_STORE, NOTIFICATION_TIMELINES_STORE, NOTIFICATIONS_STORE, PINNED_STATUSES_STORE, STATUS_TIMELINES_STORE, STATUSES_STORE, ACCOUNT_ID, REBLOG_ID, STATUS_ID, THREADS_STORE } from './constants' import { createThreadKeyRange, createTimelineKeyRange, createTimelineId, createThreadId, createPinnedStatusKeyRange, createPinnedStatusId } from './keys' import { deleteAll } from './utils' function cacheStatus (status, instanceName) { 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) } } // // pagination // async function getNotificationTimeline (instanceName, timeline, maxId, limit) { let storeNames = [NOTIFICATION_TIMELINES_STORE, NOTIFICATIONS_STORE, STATUSES_STORE, ACCOUNTS_STORE] const db = await getDatabase(instanceName) return dbPromise(db, storeNames, 'readonly', (stores, callback) => { let [ timelineStore, notificationsStore, statusesStore, accountsStore ] = stores let keyRange = createTimelineKeyRange(timeline, maxId) timelineStore.getAll(keyRange, limit).onsuccess = e => { let timelineResults = e.target.result let res = new Array(timelineResults.length) timelineResults.forEach((notificationId, i) => { fetchNotification(notificationsStore, statusesStore, accountsStore, notificationId, notification => { res[i] = notification }) }) callback(res) } }) } async function getStatusTimeline (instanceName, timeline, maxId, limit) { let storeNames = [STATUS_TIMELINES_STORE, STATUSES_STORE, ACCOUNTS_STORE] const db = await getDatabase(instanceName) return dbPromise(db, storeNames, 'readonly', (stores, callback) => { let [ timelineStore, statusesStore, accountsStore ] = stores let getReq = timelineStore.getAll(createTimelineKeyRange(timeline, maxId), limit) getReq.onsuccess = e => { let timelineResults = e.target.result let res = new Array(timelineResults.length) timelineResults.forEach((statusId, i) => { fetchStatus(statusesStore, accountsStore, statusId, status => { res[i] = status }) }) callback(res) } }) } async function getStatusThread (instanceName, statusId) { let storeNames = [THREADS_STORE, STATUSES_STORE, ACCOUNTS_STORE] const db = await getDatabase(instanceName) return dbPromise(db, storeNames, 'readonly', (stores, callback) => { let [ threadsStore, statusesStore, accountsStore ] = stores let keyRange = createThreadKeyRange(statusId) threadsStore.getAll(keyRange).onsuccess = e => { let thread = e.target.result if (thread.length) { let res = new Array(thread.length) callback(res) thread.forEach((otherStatusId, i) => { fetchStatus(statusesStore, accountsStore, otherStatusId, status => { res[i] = status }) }) } else { // thread not cached; just make a "fake" thread with only one status in it fetchStatus(statusesStore, accountsStore, statusId, status => { let res = [status] callback(res) }) } } }) } export async function getTimeline (instanceName, timeline, maxId = null, limit = 20) { if (timeline === 'notifications') { return getNotificationTimeline(instanceName, timeline, maxId, limit) } else if (timeline.startsWith('status/')) { let statusId = timeline.split('/').slice(-1)[0] return getStatusThread(instanceName, statusId) } else { return getStatusTimeline(instanceName, timeline, maxId, limit) } } // // insertion // function putStatus (statusesStore, status) { statusesStore.put(cloneForStorage(status)) } function putAccount (accountsStore, account) { accountsStore.put(cloneForStorage(account)) } function putNotification (notificationsStore, notification) { notificationsStore.put(cloneForStorage(notification)) } function storeAccount (accountsStore, account) { putAccount(accountsStore, account) } function storeStatus (statusesStore, accountsStore, status) { putStatus(statusesStore, status) putAccount(accountsStore, status.account) if (status.reblog) { putStatus(statusesStore, status.reblog) putAccount(accountsStore, status.reblog.account) } } function storeNotification (notificationsStore, statusesStore, accountsStore, notification) { if (notification.status) { storeStatus(statusesStore, accountsStore, notification.status) } storeAccount(accountsStore, notification.account) putNotification(notificationsStore, notification) } function fetchAccount (accountsStore, id, callback) { accountsStore.get(id).onsuccess = e => { callback(e.target.result) } } function fetchStatus (statusesStore, accountsStore, id, callback) { statusesStore.get(id).onsuccess = e => { let status = e.target.result callback(status) fetchAccount(accountsStore, status[ACCOUNT_ID], account => { status.account = account }) if (status[REBLOG_ID]) { fetchStatus(statusesStore, accountsStore, status[REBLOG_ID], reblog => { status.reblog = reblog }) } } } function fetchNotification (notificationsStore, statusesStore, accountsStore, id, callback) { notificationsStore.get(id).onsuccess = e => { let notification = e.target.result callback(notification) fetchAccount(accountsStore, notification[ACCOUNT_ID], account => { notification.account = account }) if (notification[STATUS_ID]) { fetchStatus(statusesStore, accountsStore, notification[STATUS_ID], status => { notification.status = status }) } } } async function insertTimelineNotifications (instanceName, timeline, notifications) { for (let notification of notifications) { setInCache(notificationsCache, instanceName, notification.id, notification) setInCache(accountsCache, instanceName, notification.account.id, notification.account) if (notification.status) { setInCache(statusesCache, instanceName, notification.status.id, notification.status) } } const db = await getDatabase(instanceName) let storeNames = [NOTIFICATION_TIMELINES_STORE, NOTIFICATIONS_STORE, ACCOUNTS_STORE, STATUSES_STORE] await dbPromise(db, storeNames, 'readwrite', (stores) => { let [ timelineStore, notificationsStore, accountsStore, statusesStore ] = stores for (let notification of notifications) { storeNotification(notificationsStore, statusesStore, accountsStore, notification) timelineStore.put(notification.id, createTimelineId(timeline, notification.id)) } }) } async function insertTimelineStatuses (instanceName, timeline, statuses) { for (let status of statuses) { cacheStatus(status, instanceName) } const db = await getDatabase(instanceName) let storeNames = [STATUS_TIMELINES_STORE, STATUSES_STORE, ACCOUNTS_STORE] await dbPromise(db, storeNames, 'readwrite', (stores) => { let [ timelineStore, statusesStore, accountsStore ] = stores for (let status of statuses) { storeStatus(statusesStore, accountsStore, status) timelineStore.put(status.id, createTimelineId(timeline, status.id)) } }) } async function insertStatusThread (instanceName, statusId, statuses) { for (let status of statuses) { cacheStatus(status, instanceName) } const db = await getDatabase(instanceName) let storeNames = [THREADS_STORE, STATUSES_STORE, ACCOUNTS_STORE] await dbPromise(db, storeNames, 'readwrite', (stores) => { let [ threadsStore, statusesStore, accountsStore ] = stores threadsStore.getAllKeys(createThreadKeyRange(statusId)).onsuccess = e => { let existingKeys = e.target.result let newKeys = times(statuses.length, i => createThreadId(statusId, i)) let keysToDelete = difference(existingKeys, newKeys) for (let key of keysToDelete) { threadsStore.delete(key) } } statuses.forEach((otherStatus, i) => { storeStatus(statusesStore, accountsStore, otherStatus) threadsStore.put(otherStatus.id, createThreadId(statusId, i)) }) }) } export async function insertTimelineItems (instanceName, timeline, timelineItems) { /* no await */ scheduleCleanup() if (timeline === 'notifications') { return insertTimelineNotifications(instanceName, timeline, timelineItems) } else if (timeline.startsWith('status/')) { let statusId = timeline.split('/').slice(-1)[0] return insertStatusThread(instanceName, statusId, timelineItems) } else { return insertTimelineStatuses(instanceName, timeline, timelineItems) } } // // get // export async function getStatus (instanceName, id) { if (hasInCache(statusesCache, instanceName, id)) { return getInCache(statusesCache, instanceName, id) } const db = await getDatabase(instanceName) let storeNames = [STATUSES_STORE, ACCOUNTS_STORE] let result = await dbPromise(db, storeNames, 'readonly', (stores, callback) => { let [ statusesStore, accountsStore ] = stores fetchStatus(statusesStore, accountsStore, id, callback) }) setInCache(statusesCache, instanceName, id, result) return result } export async function getNotification (instanceName, id) { if (hasInCache(notificationsCache, instanceName, id)) { return getInCache(notificationsCache, instanceName, id) } const db = await getDatabase(instanceName) let storeNames = [NOTIFICATIONS_STORE, STATUSES_STORE, ACCOUNTS_STORE] let result = await dbPromise(db, storeNames, 'readonly', (stores, callback) => { let [ notificationsStore, statusesStore, accountsStore ] = stores fetchNotification(notificationsStore, statusesStore, accountsStore, id, callback) }) setInCache(notificationsCache, instanceName, id, result) return result } // // lookup by reblogs // export async function getReblogsForStatus (instanceName, id) { const db = await getDatabase(instanceName) await dbPromise(db, STATUSES_STORE, 'readonly', (statusesStore, callback) => { statusesStore.index(REBLOG_ID).getAll(IDBKeyRange.only(id)).onsuccess = e => { callback(e.target.result) } }) } // // lookups by statusId // export async function getNotificationIdsForStatuses (instanceName, statusIds) { const db = await getDatabase(instanceName) return dbPromise(db, NOTIFICATIONS_STORE, 'readonly', (notificationsStore, callback) => { let res = [] callback(res) statusIds.forEach(statusId => { let req = notificationsStore.index(STATUS_ID).getAllKeys(IDBKeyRange.only(statusId)) req.onsuccess = e => { for (let id of e.target.result) { res.push(id) } } }) }) } // // deletes // export async function deleteStatusesAndNotifications (instanceName, statusIds, notificationIds) { for (let statusId of statusIds) { deleteFromCache(statusesCache, instanceName, statusId) } for (let notificationId of notificationIds) { deleteFromCache(notificationsCache, instanceName, notificationId) } const db = await getDatabase(instanceName) let storeNames = [ STATUSES_STORE, STATUS_TIMELINES_STORE, NOTIFICATIONS_STORE, NOTIFICATION_TIMELINES_STORE, PINNED_STATUSES_STORE, THREADS_STORE ] await dbPromise(db, storeNames, 'readwrite', (stores) => { let [ statusesStore, statusTimelinesStore, notificationsStore, notificationTimelinesStore, pinnedStatusesStore, threadsStore ] = stores function deleteStatus (statusId) { statusesStore.delete(statusId) deleteAll( pinnedStatusesStore, pinnedStatusesStore.index('statusId'), IDBKeyRange.only(statusId) ) deleteAll( statusTimelinesStore, statusTimelinesStore.index('statusId'), IDBKeyRange.only(statusId) ) deleteAll( threadsStore, threadsStore.index('statusId'), IDBKeyRange.only(statusId) ) deleteAll( threadsStore, threadsStore, createThreadKeyRange(statusId) ) } function deleteNotification (notificationId) { notificationsStore.delete(notificationId) deleteAll( notificationTimelinesStore, notificationTimelinesStore.index('notificationId'), IDBKeyRange.only(notificationId) ) } for (let statusId of statusIds) { deleteStatus(statusId) } for (let notificationId of notificationIds) { deleteNotification(notificationId) } }) } // // pinned statuses // export async function insertPinnedStatuses (instanceName, accountId, statuses) { for (let status of statuses) { cacheStatus(status, instanceName) } const db = await getDatabase(instanceName) let storeNames = [PINNED_STATUSES_STORE, STATUSES_STORE, ACCOUNTS_STORE] await dbPromise(db, storeNames, 'readwrite', (stores) => { let [ pinnedStatusesStore, statusesStore, accountsStore ] = stores statuses.forEach((status, i) => { storeStatus(statusesStore, accountsStore, status) pinnedStatusesStore.put(status.id, createPinnedStatusId(accountId, i)) }) }) } export async function getPinnedStatuses (instanceName, accountId) { let storeNames = [PINNED_STATUSES_STORE, STATUSES_STORE, ACCOUNTS_STORE] const db = await getDatabase(instanceName) return dbPromise(db, storeNames, 'readonly', (stores, callback) => { let [ pinnedStatusesStore, statusesStore, accountsStore ] = stores let keyRange = createPinnedStatusKeyRange(accountId) pinnedStatusesStore.getAll(keyRange).onsuccess = e => { let pinnedResults = e.target.result let res = new Array(pinnedResults.length) pinnedResults.forEach((statusId, i) => { fetchStatus(statusesStore, accountsStore, statusId, status => { res[i] = status }) }) callback(res) } }) } // // update statuses // async function updateStatus (instanceName, statusId, updateFunc) { const db = await getDatabase(instanceName) if (hasInCache(statusesCache, instanceName, statusId)) { let status = getInCache(statusesCache, instanceName, statusId) updateFunc(status) cacheStatus(status, instanceName) } return dbPromise(db, STATUSES_STORE, 'readwrite', (statusesStore) => { statusesStore.get(statusId).onsuccess = e => { let status = e.target.result updateFunc(status) putStatus(statusesStore, status) } }) } export async function setStatusFavorited (instanceName, statusId, favorited) { return updateStatus(instanceName, statusId, status => { let delta = (favorited ? 1 : 0) - (status.favourited ? 1 : 0) status.favourited = favorited status.favourites_count = (status.favourites_count || 0) + delta }) } export async function setStatusReblogged (instanceName, statusId, reblogged) { return updateStatus(instanceName, statusId, status => { let delta = (reblogged ? 1 : 0) - (status.reblogged ? 1 : 0) status.reblogged = reblogged status.reblogs_count = (status.reblogs_count || 0) + delta }) }