add ability to fetch and store notifications
This commit is contained in:
parent
43170b9f6f
commit
c8cb4354e3
|
@ -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 })
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
<article class="notification-article"
|
||||
tabindex="0"
|
||||
aria-posinset="{{index}}" aria-setsize="{{length}}"
|
||||
on:recalculateHeight
|
||||
>
|
||||
Notification
|
||||
</article>
|
|
@ -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>
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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'
|
|
@ -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
|
||||
//
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue